Now that we can work with data in a type-safe and transparent way, we need to start doing something with it. Just like we do not want random arrays full of data, we also do not want the most critical part of our project — the business functionality — to be spread throughout random classes.
Actions solve this problem. They are the single most impactful pattern you can adopt in a domain-oriented Laravel project.
What is an action?
An action is a class that represents a single user story or business operation. It takes input, does something, and gives output.
Think about what happens when an instructor publishes a course:
- Validate all lessons have content and required metadata
- Calculate the total duration from all lessons
- Generate a unique course code
- Save the course to the database
- Create enrollment slots for waitlisted students
- Send notification emails to subscribers
In a typical Laravel application, this logic lives in a fat model, a bloated controller, or is scattered across jobs and event listeners. With actions, you encapsulate the entire operation:
class PublishCourseAction
{
public function __construct(
private CreateLessonAction $createLessonAction,
private GenerateCertificateAction $generateCertificateAction,
) {}
public function execute(CourseData $courseData): Course
{
// Create course, process lessons, generate certificate template...
}
}
Actions live in the domain. They are simple classes with no abstractions or interfaces. They take input, do something, and produce output.
Naming conventions
As a convention, suffix all action classes with Action. While PublishCourse sounds clean, it could also be a controller, a command, or a job. PublishCourseAction eliminates any confusion.
Yes, class names get longer. In large projects, you might end up with names like RecalculateStudentProgressForCompletedModuleAction. That is not elegant, but it is unambiguous. Your IDE's autocompletion handles the typing for you. For a comprehensive guide to naming all parts of a Laravel application, see the Naming Conventions chapter in Clean Code in Laravel.
For the public method, use execute. Why not __invoke or handle?
Not __invoke because PHP does not let you directly invoke an invokable property on a class. When composing actions, you would need ugly syntax:
// This does not work — PHP looks for a class method, not the invokable
$this->createLessonAction($lessonData);
// You need parentheses around it
($this->createLessonAction)($lessonData);
Not handle because Laravel uses handle in jobs and commands with automatic method injection from the container. Actions should only use constructor injection, not method injection. Using handle could create confusion about when dependencies are resolved.
execute is clear, unambiguous, and available.
Why use actions?
Re-usability
The key is splitting actions into pieces that are small enough to be reusable, while large enough to avoid class explosion.
Generating a certificate happens in multiple contexts: when a course is published and when a student completes the course. Two controllers, same business logic:
// In PublishCourseController
$action = app(PublishCourseAction::class);
$course = $action->execute($courseData);
// In GenerateCertificateController
$action = app(GenerateCertificateAction::class);
$certificate = $action->execute($enrollment);
A good rule of thumb: abstract when the functionality is the same, not when the code looks similar. Two actions might have similar code but serve completely different business purposes. Do not abstract those prematurely.
Reduced cognitive load
When you need to change how courses are published, you go to one place: PublishCourseAction. You do not hunt through controllers, models, jobs, and listeners trying to piece together what happens.
Actions may work together with asynchronous jobs and event listeners, but those handle infrastructure concerns (queuing, event dispatching), not business logic. The action contains the actual rules.
Testability
Actions are trivially easy to test. The pattern is always the same:
- Setup: create DTOs and resolve the action from the container
- Execute: call the action
- Assert: check the output
it('publishes a course with correct total duration', function (): void {
// Setup
$courseData = CourseDataFactory::factory()
->addLesson(title: 'Getting Started', videoCount: 3, duration: 15)
->addLesson(title: 'Advanced Topics', videoCount: 5, duration: 45)
->create();
$action = app(PublishCourseAction::class);
// Execute
$course = $action->execute($courseData);
// Assert
$this->assertDatabaseHas($course->getTable(), ['id' => $course->id]);
expect($course->course_code)->not->toBeNull()
->and($course->total_duration)->toBe(60)
->and($course->lessons)->toHaveCount(2);
});
No fake HTTP requests, no facade mocking. Just input, execution, and assertions.
Composing actions
Actions use constructor injection for dependencies and the execute method for context-specific data. If you are unfamiliar with how Laravel's service container resolves dependencies automatically, the Dependency Injection chapter in Clean Code in Laravel covers the fundamentals. This lets you compose actions from other actions:
class CreateLessonAction
{
public function __construct(private DurationCalculator $durationCalculator) {}
public function execute(LessonData $data): Lesson
{
$baseMinutes = $data->duration_minutes;
if ($data->has_quiz) {
$totalMinutes = $this->durationCalculator
->withQuiz($baseMinutes, quizDuration: 10);
} else {
$totalMinutes = $this->durationCalculator
->withoutQuiz($baseMinutes);
}
return new Lesson([
'title' => $data->title,
'video_count' => $data->video_count,
'duration_minutes' => $totalMinutes,
'has_quiz' => $data->has_quiz,
]);
}
}
CreateLessonAction is injected into PublishCourseAction, which also injects GenerateCertificateAction and NotifyStudentsAction. Each action stays small and focused, yet together they handle complex business operations.
Be careful with deep dependency chains — they make code complex and tightly coupled. But two or three levels of composition are perfectly manageable and keep individual actions clean.
Mocking composed actions
Composition also makes testing flexible. If PublishCourseAction uses GenerateCertificateAction internally and you do not want certificates generated during a test, just mock it:
namespace Tests\Mocks\Actions;
class MockGenerateCertificateAction extends GenerateCertificateAction
{
public static function setUp(): void
{
app()->singleton(
GenerateCertificateAction::class,
fn () => new self()
);
}
public function execute(ToCertificate $toCertificate): void
{
return;
}
}
Call MockGenerateCertificateAction::setUp() in your test's setUp method, and certificate generation is silently skipped. You test course publishing without the overhead of actual certificate rendering. For a broader introduction to test doubles — dummies, stubs, fakes, and mocks — see the Introduction to Test Doubles in PHP series.
Alternatives to actions
Two patterns from DDD are worth mentioning:
Commands and handlers separate what needs to happen (the command) from how it happens (the handler). This gives more flexibility — you can swap handlers, add middleware to the command bus, etc. But it also means more boilerplate. For most Laravel projects, actions provide enough flexibility without the extra code.
Event-driven systems decouple the trigger from the behavior entirely. When a course is published, an event is dispatched, and listeners react. This is extremely flexible but adds indirection that makes code harder to follow. The benefit rarely outweighs the cost for projects that are not massive distributed systems.
Actions sit in the pragmatic middle ground: enough structure to keep code organized, enough simplicity to keep it understandable.
Together with DTOs and models, actions form the true core of your project. DTOs carry the data, actions encapsulate what you do with it, and models define how it is persisted. Everything else is infrastructure. For a complementary look at actions within Laravel's default structure — including how controllers delegate to actions and how to keep actions focused — see the Actions chapter in Clean Code in Laravel.
Summary
- An action is a class that represents a single business operation. It takes input, does something, and gives output. All business logic lives here instead of being scattered across controllers, models, and jobs.
- Name actions with an
Actionsuffix (PublishCourseAction) and useexecuteas the public method — not__invoke(broken property invocation) orhandle(confuses with Laravel's method injection in jobs). - Actions use constructor injection for dependencies and the
executemethod for context-specific data. This enables clean composition: actions can inject and call other actions. - Actions are reusable across controllers, commands, and jobs. They are testable with a simple setup-execute-assert pattern. And they reduce cognitive load — when you need to change how something works, there is one place to look.
- For composed actions, mock inner actions in tests to isolate the unit under test. A mock class that extends the real action and overrides
executekeeps things simple. - Commands and handlers or event-driven systems are alternatives, but actions sit in the pragmatic middle ground: enough structure to stay organized, enough simplicity to stay understandable.
References
- Refactoring to Actions — Freek Van der Herten
- spatie/laravel-queueable-action — Spatie, GitHub
- Introduction to Test Doubles in PHP — Ahmad Mayahi
- Actions — Clean Code in Laravel
- Dependency Injection — Clean Code in Laravel
- Single Responsibility Principle — Robert C. Martin