All posts

The Single Responsibility Principle in Laravel

A class should have one reason to change. Here is what that means in a Laravel controller, how to split the work, and where splitting turns into fragmentation.

Marijan Kopčić••4 min read
The Single Responsibility Principle in Laravel

Every Laravel project has that one class. It validates input, formats dates, writes log lines, updates three tables and sends an email, and every change to it feels like defusing a bomb. The fix has a name older than Laravel itself: the Single Responsibility Principle. A class should have one reason to change.

What "one responsibility" actually means

It does not mean "one method" or "as small as possible". It means that a class should serve one purpose, so that only one kind of change ever forces you to open it. If the logging format changes, you should edit the logger. If the rules for updating a project change, you should edit the project logic. If both changes land in the same file, that file is doing two jobs.

Here is a controller action that does four:

public function update(Request $request, Project $project): RedirectResponse
{
    $request->validate([
        'name' => 'required|max:255',
        'tasks' => 'required|array',
    ]);

    foreach ($request->tasks as $task) {
        $dueDate = Carbon::parse($task['due_date'])->toDateTimeString();
        Log::info('Task updated: '.$dueDate.' - '.$task['status']);
    }

    $project->updateTasks($request->all());

    return redirect()->route('projects.index');
}

Validation, date formatting, logging and persistence all live in one method. Four different reasons to change, one place to break.

Give each job its own home

Split the work along the lines where it would naturally change:

public function update(
    UpdateProjectRequest $request,
    Project $project,
    TaskLogger $taskLogger,
    ProjectService $projectService,
): RedirectResponse {
    $taskLogger->logTasks($request->validated('tasks'));
    $projectService->updateTasks($project, $request->validated());

    return redirect()->route('projects.index');
}

class TaskLogger
{
    public function logTasks(array $tasks): void
    {
        foreach ($tasks as $task) {
            Log::info('Task updated', [
                'due_date' => Carbon::parse($task['due_date'])->toDateTimeString(),
                'status' => $task['status'],
            ]);
        }
    }
}

class ProjectService
{
    public function updateTasks(Project $project, array $data): void
    {
        // the rules for updating a project's tasks live here
    }
}

Validation moved into a Form Request, logging into TaskLogger, the update rules into ProjectService. The controller is back to its only job: turning a request into a response.

Why this pays off

Smaller blast radius. A change to how tasks are logged cannot accidentally break how projects are saved, because the two no longer share a file.

Tests that test one thing. TaskLogger can be tested without an HTTP request. ProjectService can be tested without a logger. When a test fails, the name of the class tells you where to look.

Reuse for free. The moment an Artisan command or a queued job needs to log tasks, TaskLogger is already there. Nobody copies a loop out of a controller.

Readable code reviews. A pull request touching only TaskLogger tells the reviewer exactly what kind of change it is before they read a single line.

Where the principle gets misused

Taken literally, SRP produces a project with three hundred one-method classes and no way to follow a request from start to finish. That is not separation, it is fragmentation. A class with several methods is fine as long as they all change for the same reason. A ProjectService with create, updateTasks and archive still has one responsibility: the lifecycle of a project.

A useful rule of thumb:

  • Can you describe the class without the word "and"? Then it is probably fine.
  • Do two parts of it change on different schedules, for different people? Split them.
  • Would the split create a class nobody else will ever call? Keep it together until a second caller appears.

The practical test

Pick a class you edited last week and list the reasons it could change: a new validation rule, a new log format, a new business rule, a new notification. If the list has more than one item from different parts of the business, you have found your next refactor. Start by moving the item that changes most often into its own class, and let the rest follow when it needs to.

Keep reading