Why I Use Laravel Actions Over Jobs and Commands

Categories: Laravel
1 min read

If you've worked on a Laravel application for any length of time, you've probably hit the moment where the same piece of business logic needs to run from a controller, a queued job, and an Artisan command. You extract it into a service class, wire it up in three places, and move on. It works - but there's a better pattern. I've been using Laravel Actions across client projects for a while now, and it's changed how I organise application logic.

The Problem: Logic Scattered Everywhere

Laravel gives you jobs, commands, controllers, and listeners out of the box. They're all good at what they do, but they encourage you to think in terms of where code runs rather than what it does.

Take a common example: syncing invoices from Xero. You need to:

  • Queue a sync when a user clicks "Refresh" in the dashboard
  • Trigger a sync when a webhook arrives from Xero
  • Run a full resync manually from the command line when something goes wrong

In a traditional Laravel setup, you'd write a job for the queue, a command for the CLI, and a controller action for the HTTP trigger. The actual sync logic either lives in one of those (and gets called awkwardly from the others) or you extract it into a service class that all three depend on. Either way, that's a lot of glue code for one operation.

Actions: One Class, Multiple Contexts

With Laravel Actions, you write one class that describes what the operation does. That same class can run as a plain object, a queued job, an Artisan command, a controller, or an event listener - depending on how you call it.

use Lorisleiva\Actions\Concerns\AsAction;

class SyncXeroInvoices
{
    use AsAction;

    public string $commandSignature = 'xero:sync-invoices {--all}';

    public function handle(Organisation $org, bool $all = false): void
    {
        // The actual sync logic - one place, one class.
        $invoices = $all
            ? $org->xeroClient()->getAllInvoices()
            : $org->xeroClient()->getRecentInvoices();

        foreach ($invoices as $invoice) {
            $org->invoices()->updateOrCreate(
                ['xero_id' => $invoice->id],
                $invoice->toArray()
            );
        }
    }

    public function asCommand(Command $command): void
    {
        $org = Organisation::sole();
        $this->handle($org, $command->option('all'));
        $command->info('Invoices synced.');
    }

    public function asController(Request $request): JsonResponse
    {
        $this->handle($request->user()->organisation);
        return response()->json(['status' => 'synced']);
    }
}

Now you can dispatch it however you need:

// From a controller or webhook - queued
SyncXeroInvoices::dispatch($organisation);

// Inline when you need the result immediately
SyncXeroInvoices::run($organisation);

// From the CLI
php artisan xero:sync-invoices --all

One class. One place for the logic. Three completely different execution contexts.

Why This Beats Standalone Jobs

I still like Laravel jobs - they're excellent for queueable work, and I've written about why queues matter before. But jobs are designed around the queue. If you want to run the same logic synchronously from a controller, or manually from a command, you end up wrapping the job or duplicating the logic.

Actions solve this naturally. The handle() method is your business logic. Dispatching it as a job is just one way to run it. You don't need to think about execution context when writing the logic - you decide how to run it at the call site.

Why This Beats Standalone Commands

Artisan commands are tied to the CLI. They're great for scheduled tasks and one-off operations, but the moment you want that same logic available from a webhook or an admin panel, you're extracting it out anyway.

With Actions, CLI support is just a trait method. Add $commandSignature and asCommand() and you've got an Artisan command - but the logic stays in handle() where everything else can reach it too.

Easier to Operate Long-Term

This is the bit that really matters in practice. Applications don't just need to work on launch day - they need to work six months later when something unexpected happens.

With the Xero example: maybe an API outage means invoices get out of sync. With Actions, you already have php artisan xero:sync-invoices --all ready to go. You don't need to write a new command, figure out how to call a job manually, or SSH in and tinker. The flexibility is built in from day one, and it costs you nothing extra.

This is something I come back to again and again when building applications for clients in Bath and Bristol. Startups move fast, requirements shift, and the unexpected happens regularly. Having business logic that's easy to trigger in whatever context you need is a real operational advantage.

When I Still Reach for Plain Jobs or Commands

Actions aren't a replacement for everything. I still use plain jobs for things that are only ever queued - fire-and-forget tasks like sending a notification where there's no conceivable need to run the same logic from the CLI or inline. And I'll use a plain command for one-off maintenance scripts that will never be triggered from anywhere else.

The rule of thumb: if the logic might need to run in more than one context - now or in the future - it's an Action. If it's truly single-context, keep it simple.

Common Questions

What is the Laravel Actions package?

Laravel Actions is a package by Loris Leiva that lets you write one class for a piece of business logic and run it as a controller, job, command, or listener. Instead of organising code by where it runs, you organise it by what your application does. It's a small shift in thinking that cleans up a lot of boilerplate.

Can Laravel Actions replace all jobs and commands?

Not always - and that's fine. Plain jobs and commands still make sense for things that genuinely only run in one context. A fire-and-forget notification job doesn't need CLI support. A one-off data migration command doesn't need a controller. Use Actions when the logic needs flexibility; keep things simple when it doesn't.

Do Actions make testing harder?

The opposite. Because an Action is a plain class with a handle() method, you can test it directly without simulating HTTP requests or Artisan calls. Pass in the dependencies, call handle(), and assert the result. It's one of the cleanest testing patterns in Laravel.

If you're building a Laravel application and want to keep your codebase clean as it grows, Actions are worth a look. For more on structuring Laravel projects, see scaling your Laravel backend and why queues matter for startups.

If you're in Bath, Bristol, or Wiltshire and need a freelance Laravel developer, let's talk.

Ben Lumley StackOverflow Github Linkedin

Related posts