laraditz/action
A Laravel package for single-action classes that keep your app DRY. Each action handles one business operation, takes input via its constructor, resolves dependencies automatically through Laravel's container, and supports queueing.
Install and read the source first. The whole package is essentially one class plus one Artisan command, so before writing any actions, spend two minutes in vendor/laraditz/action/src/:
composer require laraditz/action
# then read it — it's tiny
cat vendor/laraditz/action/src/Action.php
You need to confirm three things from that file: how run() resolves handle() (it uses app()->call([$this, 'handle'])), what data() actually reflects, and where make:action writes files. Everything else in this assessment follows from those three facts.
Generate your first action.
php artisan make:action CreateNewPost
# writes app/Actions/CreateNewPost.php
namespace App\Actions;
use App\Models\Post;
use Laraditz\Action\Action;
class CreateNewPost extends Action
{
public function __construct(
public string $title,
public string $body,
) {}
public function handle(): Post
{
return Post::create($this->data());
}
}
Call it with named arguments — this is the style that pays off in review diffs, because the call site reads like a sentence and adding a constructor parameter is a compile-visible change at every call site:
$post = CreateNewPost::run(title: 'Hello', body: 'World');
Best first use case: take one controller method that is already 30+ lines of orchestration (checkout, onboarding, invite-user), lift the body into an action, and leave validation/authorization in the controller. Don't start by refactoring your whole app into actions — pick the method you're most afraid of.
Check availability of a config. The 1.0.0 changelog mentions a config file and a Resolvable trait; the 2.x revamp and the current README show neither. If you need to change the namespace or output path for make:action, run php artisan vendor:publish and php artisan make:action --help before assuming a config knob exists.
Constructor carries input, handle() carries behaviour. The constructor is your data contract; handle() is your execution contract. Keep them that way:
class PublishPost extends Action
{
public function __construct(
public readonly Post $post,
public readonly string $authorEmail,
) {}
public function handle(Mailer $mailer): Post
{
$this->post->update(['published_at' => now()]);
$mailer->to($this->authorEmail)->send(new PostPublished($this->post));
return $this->post->refresh();
}
}
Put services in the handle() signature, not the constructor. This is the single most important usage rule and it's what makes queue support work at all: constructor args get serialized into the job payload; handle() args are resolved fresh by the container on the worker. Anything that isn't cleanly serializable — a mailer, a repository with a PDO handle, a closure — belongs in handle().
// Queueable action — services are injected at execution time
class SendWelcomeEmail extends Action implements ShouldQueue
{
use Queueable, InteractsWithQueue, SerializesModels;
public function __construct(
public string $email,
public string $name,
) {}
public function handle(Mailer $mailer): void
{
$mailer->to($this->email)->send(new WelcomeEmail($this->name));
}
}
SendWelcomeEmail::dispatch(email: '[email protected]', name: 'Alice');
Do not add Illuminate\Foundation\Bus\Dispatchable — the base class already supplies dispatch().
One action, one entry point, four call sites. The consistency win is calling actions identically from every context:
// Controller
public function store(StorePostRequest $request)
{
$this->authorize('create', Post::class);
$post = CreateNewPost::run(...$request->validated());
return redirect()->route('posts.show', $post);
}
// Console command
CreateNewPost::run(title: $this->argument('title'), body: $this->option('body'));
// Listener
class HandleUserSignedUp
{
public function handle(UserSignedUp $event): void
{
CreateNewPost::run(title: 'Welcome', body: $event->user->bio);
}
}
// Another action
class OnboardUser extends Action
{
public function handle(): void
{
CreateNewPost::run(title: 'First post', body: 'Hello world');
}
}
Use data() only for flat, purely-promoted, single-purpose actions. It's a genuinely nice shortcut for Post::create($this->data()) when the action exists solely to insert a record. The moment the action grows a second responsibility (an actor, a flag, a model), stop using it and write the array explicitly.
Wrap side effects in a transaction at the action boundary, not in the controller — otherwise the command and job call sites lose the guarantee:
public function handle(Mailer $mailer): Post
{
return DB::transaction(function () use ($mailer) {
$post = Post::create(['title' => $this->title, 'body' => $this->body]);
$mailer->to($this->authorEmail)->send(new PostCreated($post));
return $post;
});
}
If you want this on every action, add an abstract BaseAction extends Action in your own app/Actions/ and change the make:action stub (or a published stub, if the package exposes one) so new actions extend yours. This is the cheapest extension point and it costs you nothing if the package is later abandoned.
Testing pattern. Because actions are plain instantiable classes, testing is trivial and should be the reason you adopt the pattern:
it('creates a post', function () {
$post = CreateNewPost::run(title: 'Hello', body: 'World');
expect($post)->toBeInstanceOf(Post::class)
->and($post->title)->toBe('Hello');
});
it('dispatches the welcome email', function () {
Mail::fake();
SendWelcomeEmail::run(email: '[email protected]', name: 'Alice');
Mail::assertSent(WelcomeEmail::class);
});
it('queues the action', function () {
Queue::fake();
SendWelcomeEmail::dispatch(email: '[email protected]', name: 'Alice');
Queue::assertPushed(SendWelcomeEmail::class);
});
Inject fakes for handle() dependencies with $this->mock(Mailer::class, ...) or $this->app->instance(...) in the test's setup — the base class resolves through the container, so container bindings are your seam.
data() is a mass-assignment footgun. It returns every promoted constructor property, so Model::create($this->data()) silently changes what's persisted the day someone adds userId or isAdmin to the constructor. Defend on both sides: keep $fillable strict on the model, and prefer explicit arrays in any action that isn't a pure insert. Code review should treat create($this->data()) as a yellow flag.
data() only sees promoted properties. A property declared in the class body but assigned in the constructor won't appear:
class Bad extends Action
{
public string $title; // NOT in data()
public function __construct(string $title) // NOT promoted
{
$this->title = $title;
}
}
Always use constructor property promotion if you intend to call data(). Also beware of non-scalar promoted props — a Post or a DTO in the constructor lands in the array and create() will choke on it.
Don't add Dispatchable. The README says it conflicts with the base class's dispatch(); if you paste a job-like class and get weird behaviour or a method collision, the trait is the culprit. Keep only Queueable, InteractsWithQueue, SerializesModels.
Queued actions can't take unserializable constructor args. Closures, service instances, PDO handles, and non-serializable DTOs will fail at push time or silently corrupt the payload. Rule of thumb: constructor args should be scalars, models (with SerializesModels), or plain DTOs. Everything else goes in handle().
run() is not __call magic anymore — it's the contract. In 2.1.0 the instance __call magic was replaced with container-aware run(). If you're following a tutorial written against 1.x (which also had a Resolvable trait and a config), it will not apply. Pin ^2.1 in composer.json and treat a 3.0 as a migration event, not a patch.
Validation and authorization do not travel with the action. This is the biggest incident risk in the pattern: your controller validates the FormRequest, then a job or command calls the same action with unvalidated input and nothing complains. Two options — either keep actions strictly internal and treat all callers as responsible, or give actions a rules()/authorize() method and have a small wrapper enforce it. Pick one explicitly and document it.
Debugging: read the stack trace, not the package. Because handle() is invoked via app()->call(), stack traces show Container::call frames between your caller and your action. That's expected. If a dependency isn't injecting, the error is usually an unbound interface — Target [FooInterface] is not instantiable — not a package bug.
Nested actions compose, but transactions and events don't cascade. SomeAction::run() inside another action is fine and readable; just remember an outer DB::transaction is your only atomicity guarantee, and no domain events fire automatically. If you need chaining/compensation, build it deliberately — this package gives you nothing for free there.
Where to extend rather than fork. Add an app/Actions/BaseAction.php for transactions, logging, and return-type enforcement; override the generator stub if the package allows; keep a composer.lock pin. The package surface is small enough that if maintenance stalls, inlining Action.php into app/Support/ is an afternoon of work — so treat this as a convention enabler you own, not a vendor dependency you rely on. With zero dependents and a single maintainer, budget for that fork and don't spread the base class across hundreds of actions without a plan to replace it.
How can I help you explore Laravel packages today?