Services, Actions & Architecture
Keeping controllers thin: service classes, single-action classes and DTOs.
A fresh Laravel app puts logic in controllers and models, and for small apps that's fine. As an app grows, the same business rule ends up needed in a controller, an Artisan command, a queued job and a test. Copying it four times is how bugs start.
This lesson covers the small, practical patterns Laravel developers use to keep that logic in one place: service classes, single-purpose actions and data transfer objects (DTOs). Each one is shown with real code from this app.
The problem: fat controllers
Here is the kind of controller that grows naturally. Illustrative:
public function due(Request $request)
{
$days = (int) $request->input('days', 7);
$tasks = Task::whereNull('completed_at')
->whereNotNull('due_on')
->whereDate('due_on', '<=', today()->addDays($days))
->with(['project', 'assignee'])
->orderBy('due_on')
->get();
return $tasks->map(fn ($task) => [
'title' => $task->title,
'days_left' => today()->diffInDays($task->due_on, false),
// ...
]);
}
It works, until you also need "what's due soon?" in a nightly command, a dashboard widget and an email. Each copy drifts slightly: one forgets whereNotNull, another counts days differently. The fix is to give the rule a name and a home.
Layers at a glance
The rule of thumb: controllers and commands are adapters. They turn HTTP or CLI input into plain PHP values, call the domain code, and turn the result back into JSON or terminal text. The domain code knows nothing about requests or consoles.
Actions: one class, one job
An action is a class with a single public method that does one thing the business cares about. It is named as a verb, like FindDueTasks, CompleteTask or InviteMember. This is the real action behind the scheduling lesson's command:
app/Actions/FindDueTasks.php
/**
* Open tasks that are overdue or due within the next $withinDays days.
*
* Used by both the `tasks:due-soon` Artisan command and the
* GET /api/demo/tasks/due endpoint — one piece of logic, two entry points.
*/
class FindDueTasks
{
/**
* @return Collection<int, DueTaskData>
*/
public function handle(int $withinDays = 7, ?string $projectSlug = null): Collection
{
$today = CarbonImmutable::today();
return Task::query()
->open()
->with(['project', 'assignee'])
->whereNotNull('due_on')
->whereDate('due_on', '<=', $today->addDays($withinDays))
->when($projectSlug, fn ($query, string $slug) => $query->whereRelation('project', 'slug', $slug))
->orderBy('due_on')
->get()
->map(fn (Task $task): DueTaskData => DueTaskData::fromTask($task, $today));
}
}
Its inputs are plain values (int, ?string), not a Request. That's what makes it reusable. It also reuses the model's open() scope, so "what counts as open" is defined in exactly one place.
Here is entry point one, the HTTP controller. It validates, calls, and formats:
app/Http/Controllers/Api/DueTasksController.php
public function __invoke(Request $request, FindDueTasks $findDueTasks): JsonResponse
{
$validated = $request->validate([
'days' => ['nullable', 'integer', 'between:0,365'],
'project' => ['nullable', 'string', 'exists:projects,slug'],
]);
$tasks = $findDueTasks->handle((int) ($validated['days'] ?? 7), $validated['project'] ?? null);
return response()->json([
'data' => $tasks->map(fn (DueTaskData $task): array => $task->toArray())->values(),
'meta' => [
'total' => $tasks->count(),
'overdue' => $tasks->filter->isOverdue()->count(),
],
]);
}
Entry point two is the Artisan command. It makes the same call and renders a table instead of JSON. Run both and compare: same tasks, same order, same "days left".
Both the controller and the command receive the action by type-hinting it. The service container builds it, along with anything its constructor needs. Nothing calls new FindDueTasks(), which is what lets tests swap it for a fake (see Service Container & Facades).
DTOs: typed data instead of loose arrays
An action has to return something. Returning arrays like ['title' => …, 'days_left' => …] means every caller has to guess the keys. A data transfer object is a small immutable class that names and types each field:
app/Data/DueTaskData.php
/**
* A plain, typed snapshot of an open task with a due date.
*/
final readonly class DueTaskData
{
public function __construct(
public int $id,
public string $title,
public string $project,
public ?string $assignee,
public CarbonImmutable $dueOn,
public int $daysLeft,
) {}
public static function fromTask(Task $task, CarbonImmutable $today): self
{
$dueOn = CarbonImmutable::parse($task->due_on)->startOfDay();
return new self(
id: $task->id,
title: $task->title,
project: $task->project->name,
assignee: $task->assignee?->name,
dueOn: $dueOn,
daysLeft: (int) $today->diffInDays($dueOn, false),
);
}
public function isOverdue(): bool
{
return $this->daysLeft < 0;
}
The modern PHP features carry most of the weight:
readonly classmakes every property immutable once constructed, so nothing downstream can quietly changedaysLeft.- Constructor promotion declares and assigns each property in one line.
- Named arguments (
title: $task->title) make the factory method read like a form. - Behaviour lives with the data.
isOverdue()sits next to the fields it depends on, so the command and the controller both ask the DTO instead of re-deriving the rule.
Services: a home for related operations
Where an action does one thing, a service groups a few related operations around one concept, often with shared configuration. This app's dashboard numbers live in one:
app/Services/ProjectStats.php (excerpt)
class ProjectStats
{
public const CACHE_KEY = 'demo.project-stats';
public const TTL_SECONDS = 60;
public function get(): array { /* Cache::remember(...) around compute() */ }
public function forget(): void
{
Cache::forget(self::CACHE_KEY);
}
private function compute(): array { /* the aggregate queries */ }
}
The cache key, its lifetime, how to read the stats and how to invalidate them all live together. A model event, an admin button or a deploy hook can call forget() without knowing the key. The Caching lesson walks through it in full.
Another service you've already used on every lesson page is App\Services\TutorialRepository. It hides where lessons come from (a config file plus Markdown on disk) behind methods like find() and render(), so the controller never touches the filesystem.
Where does each piece of code go?
| Code | Home | Example in this app |
|---|---|---|
| Reading HTTP input, choosing a response | Controller (thin) | Api\DueTasksController |
| Validation and authorisation of a request | Form Request / Policy | StoreTaskRequest, TaskPolicy |
| A reusable query condition | Model scope | Task::open(), Task::search() |
| One business operation | Action | FindDueTasks |
| Several operations around one concept | Service | ProjectStats, TutorialRepository |
| Typed data passed between layers | DTO | DueTaskData |
| Shaping models into JSON | API Resource | TaskResource |
| "Something happened, others may react" | Event + listeners | TaskCompleted → LogTaskCompletion |
| Slow work outside the request | Queued job / listener | LogTaskCompletion (ShouldQueue) |
None of these needs a package or a base class. They are plain PHP classes in folders you create (app/Actions, app/Data, app/Services), generated with php artisan make:class Actions/FindDueTasks.
Testing gets simpler
Because the action takes plain values, you can test the rule without HTTP at all. Then the controller and command tests only need to check their own adapter work. The real command test exercises the action through the command:
tests/Feature/Demo/TasksDueSoonCommandTest.php (excerpt)
$this->artisan('tasks:due-soon', ['--days' => 7])
->expectsOutputToContain('Late task')
->doesntExpectOutputToContain('Far future')
->expectsOutputToContain('1 task(s), 1 overdue.')
->assertSuccessful();
The endpoint test asserts on the same rule through JSON:
tests/Feature/Demo/DemoExtrasApiTest.php (excerpt)
$this->getJson(route('api.demo.tasks.due', ['days' => 7]))
->assertOk()
->assertJsonPath('meta.total', 2)
->assertJsonPath('meta.overdue', 1)
->assertJsonPath('data.0.title', 'Late')
->assertJsonPath('data.0.days_left', -2)
->assertJsonPath('data.1.overdue', false);
When a controller depends on something slow or external, swap it in the container for that test: $this->mock(FindDueTasks::class)->shouldReceive('handle')->andReturn(collect()).
Don't over-architect
Patterns have a cost: more files, and more jumping between them. Reasonable defaults:
- Start in the controller. Extract an action the second time you need the logic, or when the controller method stops fitting on a screen.
- Don't wrap Eloquent in a repository "just in case". Eloquent already is a data-access layer, and scopes cover most reuse. Reach for a repository only when the data genuinely comes from somewhere else, as this site's
TutorialRepositorydoes with Markdown files. - Prefer plain classes to interfaces until you actually have two implementations.
- Keep folder names boring and consistent. Anyone joining the project should guess where code lives.
Key takeaways
- Controllers, commands and jobs are adapters. They translate input and output, and delegate the real work.
- An action (
FindDueTasks) holds one business operation with plain-value inputs, so any entry point can reuse it. This app's endpoint and Artisan command share one. - A DTO (
final readonly class DueTaskData) replaces loose arrays with named, typed, immutable fields, and can carry small behaviours likeisOverdue(). - A service (
ProjectStats) groups related operations and their configuration, such as a cache key, behind one API. - Inject classes through the container, extract only when duplication or size demands it, and avoid layers that don't earn their keep.