Passing arguments

run() and dispatch() accept the arguments of handle() in three shapes. The same action can be called with any of them, which is what lets one class serve a controller with a request array, a console command with flags and a test with literals.

class SendWelcomeEmail extends Action
{
    public function handle(string $email, string $subject) { /* ... */ }
}
$action->run('user@example.com', 'Welcome');                            // positional
$action->run(email: 'user@example.com', subject: 'Welcome');            // named
$action->run(['email' => 'user@example.com', 'subject' => 'Welcome']);  // keyed by parameter name

The array form is the one that pays off in practice, because it is the shape data already has when it arrives:

SendWelcomeEmail::create()->run($request->validated());

How the mapping works

run() reflects on handle() and resolves each parameter in order, by name first:

  1. If an argument was given for that parameter name, it wins.
  2. Otherwise the next unused positional argument is taken.
  3. Otherwise the default declared on handle() is used.
  4. Otherwise the parameter is filled with null.

Step four is worth reading twice. A forgotten argument does not raise "too few arguments": it arrives as null, and what happens next depends on the signature. A string $email parameter will throw a TypeError when handle() runs, while a ?string $email will quietly accept it. This is why $rules matters even for internal actions: it turns a silent null into a clear validation error before handle() is entered. See Validation.

Defaults are honoured, so an optional parameter behaves as it would in plain PHP:

class Greet extends Action
{
    public function handle(string $name, string $greeting = 'Hello')
    {
        // ...
    }
}

Greet::create()->run(name: 'Alice');   // $greeting === 'Hello'

The single array parameter

There is one rule to know, and it exists so arrays behave the way they do in PHP itself. When handle() declares exactly one parameter and that parameter accepts an array, an array passed to run() is forwarded whole as that argument rather than being spread over parameter names:

class CreateInvoice extends Action
{
    public function handle(array $attributes)
    {
        // $attributes === ['concept' => 'Consulting', 'amount' => 1200]
    }
}

CreateInvoice::create()->run(['concept' => 'Consulting', 'amount' => 1200]);

That is the attribute bag: useful when the action takes a payload rather than a fixed list of arguments, for instance when it feeds a create() on a model.

A parameter "accepts an array" when it is untyped, or typed array, iterable or mixed, or a union that includes one of those. A concrete type does not, so a single array is mapped by name instead and an object is passed positionally:

class ProcessFile extends Action
{
    public function handle(File $file) { /* ... */ }
}

ProcessFile::create()->run(['file' => $file]);  // mapped by name
ProcessFile::create()->run($file);              // positional
handle() signature run(['file' => $file]) binds
handle(array $attributes) $attributes = ['file' => $file]
handle(mixed $attributes) $attributes = ['file' => $file]
handle(File $file) $file = $file
handle(File $file, ?string $note = null) $file = $file, $note = null

Two or more parameters are never a bag: keys are always matched to names.

Arguments as properties: with()

with() sets declared properties on the action instead of passing arguments to handle(). It takes an array or named arguments, and returns the action so it chains:

$action = SendWelcomeEmail::create()->with([
    'email' => 'user@example.com',
    'subject' => 'Welcome!',
]);

$action->run();

Inside the action you read them as properties:

class SendWelcomeEmail extends Action
{
    protected string $email;
    protected string $subject;

    public function handle(): void
    {
        Mail::to($this->email)->send(new WelcomeMail($this->subject));
    }
}

The catch, and it is the reason with() sometimes appears to do nothing: the property has to be declared on the class. with() walks the public and protected properties the class actually has and assigns the ones whose names match. A key with no matching property is ignored rather than creating a dynamic attribute.

Because with() values live on the instance and not in the call, they are also outside the reach of $rules, which validates the arguments resolved for handle(). Configuration of the action is a good fit for with(); the data of the operation is better as handle() parameters, where it gets validated and traced.