When using ->concurrent() on class-based invokable tools, concurrent execution via Laravel's Concurrency::run() fails because:
-
Circular reference in Tool objects: The docs instruct class-based tools to call ->using($this), which stores the Tool instance inside its own $fn property (Tool -> $fn -> Tool). This creates a circular reference that SerializableClosure cannot serialize.
-
CallsTools closures capture $this: The closures passed to Concurrency::run() in executeToolsWithConcurrency are not static, so they implicitly capture $this (the Stream/Text handler), which transitively includes the entire application container. This causes serialization failures with Laravel's ProcessDriver (e.g. readonly property errors from packages like spatie/laravel-data, or Closure properties from dedoc/scramble).
-
resolveTool called inside the serialized closure: executeToolCall resolves the tool internally, meaning the $tools array (containing all Tool instances with their circular references) gets serialized into the closure.
Here are the specific error examples we encountered:
Dedoc\Scramble: Closure property serialization failure
When Laravel's ProcessDriver tries to serialize the closure captured by Concurrency::run(), it encounters Dedoc\Scramble\GeneratorConfig which has a Closure property. SerializableClosure tries to wrap it and assign it back, but the property has a union type that doesn't accept the wrapper:
TypeError: Cannot assign Laravel\SerializableClosure\Serializers\Native
to property Dedoc\Scramble\GeneratorConfig::$uiRoute
of type Closure|string|null
#0 vendor/laravel/serializable-closure/src/Serializers/Native.php(229):
Laravel\SerializableClosure\Serializers\Native::wrapClosures(
Object(Dedoc\Scramble\GeneratorConfig),
Object(Laravel\SerializableClosure\Support\ClosureScope)
)
This happens because the non-static closure in CallsTools captures $this (the Stream/Text handler), which holds a reference to the Laravel container, which transitively includes every registered singleton — including Dedoc\Scramble\GeneratorConfig.
Spatie\LaravelData: Readonly property serialization failure
Same root cause. The serializer walks the entire object graph captured by $this and tries to modify a readonly property on Spatie\LaravelData\Support\DataType:
Error: Cannot modify readonly property Spatie\LaravelData\Support\DataType::$type
#0 vendor/laravel/serializable-closure/src/Serializers/Native.php(286):
Laravel\SerializableClosure\Serializers\Native::wrapClosures(...)
SerializableClosure internally clones objects and tries to recursively wrap any closures it finds. When it hits a readonly property, the assignment fails because PHP prohibits modification of readonly properties after initialization.
Reproduction
class TestTool extends Tool
{
public function __construct()
{
parent::__construct();
$this
->as('test')
->for('Test tool')
->concurrent()
->using($this);
}
public function __invoke(): string
{
return 'Test';
}
}
$result = Concurrency::run([
'a' => static function () {
sleep(1);
return 'First';
},
'b' => static function () {
sleep(1);
return 'Second';
},
'c' => static function () {
$tool = new TestTool();
return $tool();
},
]);
dd($result);
// When Prism calls this tool concurrently via Concurrency::run(),
// the ProcessDriver tries to serialize the Tool object, which fails due to
// the circular reference and the implicit $this capture in CallsTools closures.
Proposed Fix
1. Tool::handle(): Prefer __invoke() over $fn for class-based tools
This eliminates the need for ->using($this) entirely on invokable tool classes, breaking the circular reference. Inline tools defined with ->using(fn () => ...) continue to work via $fn.
// src/Tool.php
public function handle(...$args): string|ToolOutput
{
try {
$callable = method_exists($this, '__invoke') ? $this : $this->fn;
$value = call_user_func($callable, ...$args);
// ...
}
}
2. CallsTools::executeToolsWithConcurrency(): Use static closures and resolve tools outside
This prevents the Stream/Text handler ($this) and its entire object graph from being serialized. The tool is resolved before the closure is created and passed as a plain argument.
// src/Concerns/CallsTools.php
protected function executeToolsWithConcurrency(array $tools, array $groupedToolCalls, string $messageId): array
{
$results = [];
$concurrentClosures = [];
foreach ($groupedToolCalls['concurrent'] as $index => $toolCall) {
$tool = $this->resolveTool($toolCall->name, $tools);
$concurrentClosures[$index] = static fn () => self::executeToolCall($tool, $toolCall, $messageId);
}
if ($concurrentClosures !== []) {
foreach (Concurrency::run($concurrentClosures) as $index => $result) {
$results[$index] = $result;
}
}
foreach ($groupedToolCalls['sequential'] as $index => $toolCall) {
$tool = $this->resolveTool($toolCall->name, $tools);
$results[$index] = self::executeToolCall($tool, $toolCall, $messageId);
}
return $results;
}
protected static function executeToolCall(Tool $tool, ToolCall $toolCall, string $messageId): array
{
// ... existing logic, but receives the resolved Tool directly
}
Additional Notes
- Users should also avoid injecting large objects (e.g. Eloquent models with massive JSON columns) into tool constructors, as the serialized payload can exceed the OS
ARG_MAX limit. Prefer passing IDs and fetching within __invoke().
- The
->using($this) call is redundant for class-based invokable tools since handle() can call __invoke() directly. The docs should be updated to remove ->using($this) from class-based tool examples, or at minimum document it as unnecessary.
When using
->concurrent()on class-based invokable tools, concurrent execution via Laravel'sConcurrency::run()fails because:Circular reference in
Toolobjects: The docs instruct class-based tools to call->using($this), which stores theToolinstance inside its own$fnproperty (Tool -> $fn -> Tool). This creates a circular reference thatSerializableClosurecannot serialize.CallsToolsclosures capture$this: The closures passed toConcurrency::run()inexecuteToolsWithConcurrencyare notstatic, so they implicitly capture$this(theStream/Texthandler), which transitively includes the entire application container. This causes serialization failures with Laravel'sProcessDriver(e.g.readonlyproperty errors from packages likespatie/laravel-data, orClosureproperties fromdedoc/scramble).resolveToolcalled inside the serialized closure:executeToolCallresolves the tool internally, meaning the$toolsarray (containing all Tool instances with their circular references) gets serialized into the closure.Here are the specific error examples we encountered:
Dedoc\Scramble: Closure property serialization failure
When Laravel's
ProcessDrivertries to serialize the closure captured byConcurrency::run(), it encountersDedoc\Scramble\GeneratorConfigwhich has aClosureproperty.SerializableClosuretries to wrap it and assign it back, but the property has a union type that doesn't accept the wrapper:This happens because the non-
staticclosure inCallsToolscaptures$this(the Stream/Text handler), which holds a reference to the Laravel container, which transitively includes every registered singleton — includingDedoc\Scramble\GeneratorConfig.Spatie\LaravelData: Readonly property serialization failure
Same root cause. The serializer walks the entire object graph captured by
$thisand tries to modify areadonlyproperty onSpatie\LaravelData\Support\DataType:SerializableClosureinternally clones objects and tries to recursively wrap any closures it finds. When it hits areadonlyproperty, the assignment fails because PHP prohibits modification ofreadonlyproperties after initialization.Reproduction
Proposed Fix
1.
Tool::handle(): Prefer__invoke()over$fnfor class-based toolsThis eliminates the need for
->using($this)entirely on invokable tool classes, breaking the circular reference. Inline tools defined with->using(fn () => ...)continue to work via$fn.2.
CallsTools::executeToolsWithConcurrency(): Usestaticclosures and resolve tools outsideThis prevents the
Stream/Texthandler ($this) and its entire object graph from being serialized. The tool is resolved before the closure is created and passed as a plain argument.Additional Notes
ARG_MAXlimit. Prefer passing IDs and fetching within__invoke().->using($this)call is redundant for class-based invokable tools sincehandle()can call__invoke()directly. The docs should be updated to remove->using($this)from class-based tool examples, or at minimum document it as unnecessary.