opscale-co/actions
Opscale Actions is a Laravel package for encapsulating business logic in reusable, testable action classes. Compose actions with dependency injection and validation to keep controllers and models thin, improve code organization, and make complex workflows easier to test and maintain.
opscale-co/actions gives you a single place to put business logic: a small, invokable class per operation instead of stuffing it into controllers, jobs, or model observers.
composer require opscale-co/actions
php artisan vendor:publish --tag=actions-config
make command signature — that tells you the intended class shape before you read anyone else's code.OrderController@store) and extract it verbatim into an action class. Call it from the controller; confirm behaviour is unchanged. This is the lowest-risk way to learn the resolver semantics and return-value conventions.If you are on v4.1.0 or later, also skim the listener support (see below) — it changes how much duplication you need when the same operation is triggered both from HTTP and from domain events.
One class, one verb. Actions are resolved through the container, so constructor injection of repositories, services, and config is the normal dependency path; method arguments are for runtime input. Keep actions free of Request objects — pass validated DTOs or scalars in.
Controller → action. Thin controllers: validate in a FormRequest, hand primitives to the action, and let the action own transactions and domain events. Returning a value from the action (a model, a result object) keeps the controller testable without touching the database.
Chaining/composition. Compose actions by injecting one action into another rather than making calls through the container inside handle(). This keeps the graph explicit and makes mocking trivial in tests.
Queued actions. Where the package offers queueing, prefer dispatching the action as a job over wrapping it in one — a wrapped job usually ends up re-implementing retry/backoff config the package already handles.
Event listeners (new in 4.1.0). #[AsListener] plus ListenerAdapter lets an action class double as an event listener. This is the right tool when the same operation fires from a controller and from a domain event:
#[AsListener(Event::class)]
final class SyncInventory implements ShouldQueue
{
public function handle(Event $event): void { /* ... */ }
}
Use it to avoid parallel FooAction / FooListener pairs that inevitably drift. Keep listener-facing handle() signatures typed against the event, and delegate to a shared method if the action also has an HTTP entry point.
Integration tips. Register listeners through the attribute where possible so discovery is co-located with the code; keep the action's handle() free of event() calls that would recurse into the listener path.
new Action() directly you bypass that and get confusing "too few arguments" errors. Always resolve via the container, or use the package's dispatch helper.#[AsListener] classes are not being invoked, check that the listener-discovery path in the config covers your app/Actions namespace and that any cached event map is cleared (php artisan event:clear / optimize:clear) — cached maps are the usual culprit after upgrading to 4.1.0.ListenerAdapter bridges the action to the dispatcher; if your handle() type-hints a specific event class, mismatches surface as TypeError at dispatch time, not at boot. Write one assertion test per listener.ShouldQueue on the listener path.DB::transaction() inside the action, not the controller, and use afterCommit for side effects — otherwise listener wiring multiplies the number of places that can fire events mid-transaction.How can I help you explore Laravel packages today?