Index

Single Responsibility Is About Change, Not Class Size

A class with one public method can still mix pricing policy, PDF layout, storage, and email delivery:

final class SendInvoice
{
    public function handle(Order $order): void
    {
        $tax = $order->subtotal * config('billing.tax_rate');
        $pdf = Pdf::html(view('invoice', compact('order', 'tax'))->render());
        Storage::disk('invoices')->put("{$order->id}.pdf", $pdf);
        Mail::to($order->customer)->send(new InvoiceMail($pdf));
    }
}

Its method count is one. Its reasons to change include tax rules, document presentation, retention, storage infrastructure, and delivery policy. “Do one thing” is too vague to expose that coupling.

The Single Responsibility Principle is more useful as a change rule: group code that changes for the same reason and separate code that changes for different reasons. Robert Martin’s clarification of SRP connects those reasons to the people or business functions requesting them.

Find the decisions hidden inside the procedure

The invoice procedure crosses four distinct decisions:

Billing policy      What amounts and tax appear on the invoice?
Document design     How is that information rendered?
Record retention    Where and for how long is the artifact stored?
Delivery policy     Who receives it, when, and through which channel?

Those decisions may change independently and have different evidence. A tax change needs billing examples. A layout change needs rendered-document tests. An S3 migration needs adapter integration tests. A resend policy needs delivery and idempotency tests.

Separate those policies while keeping the use case visible:

final readonly class IssueInvoice
{
    public function __construct(
        private InvoiceFactory $invoices,
        private InvoiceRenderer $renderer,
        private InvoiceArchive $archive,
        private InvoiceDelivery $delivery,
    ) {}

    public function handle(OrderId $orderId): void
    {
        $invoice = $this->invoices->forOrder($orderId);
        $document = $this->renderer->render($invoice);
        $location = $this->archive->store($invoice->id, $document);

        $this->delivery->send($invoice, $location);
    }
}

IssueInvoice coordinates one application capability. Four dependencies do not give it four responsibilities; orchestration is its responsibility. The collaborators own decisions that can vary without rewriting the workflow.

Cohesion matters more than extraction count

Mechanical SRP refactors produce classes such as InvoiceFilenameBuilder, InvoicePathBuilder, and InvoiceExtensionProvider that always change together. The code is smaller but the change still spans several files.

Keep behavior together when it shares one vocabulary, owner, and invariant:

final readonly class Invoice
{
    /** @param list<InvoiceLine> $lines */
    public function __construct(
        public InvoiceId $id,
        public CustomerId $customerId,
        public array $lines,
        public Money $tax,
    ) {}

    public function total(): Money
    {
        return array_reduce(
            $this->lines,
            fn (Money $total, InvoiceLine $line) => $total->add($line->total),
            $this->tax,
        );
    }
}

Lines, tax, and total participate in one billing concept. Splitting each calculation solely because it is a separate verb would lower cohesion.

Class length remains a useful prompt. A large class is more likely to contain unrelated decisions. It is not proof. A 20-line class that couples domain policy to a vendor SDK violates the change boundary more clearly than a 200-line parser implementing one stable grammar.

Framework layers are not responsibilities

Moving code from a controller into a class named OrderService does not identify why it changes:

final class OrderService
{
    public function place(array $input): Order;
    public function cancel(int $id): void;
    public function export(CarbonPeriod $period): string;
    public function sendReminder(int $id): void;
}

The name groups operations because they mention orders. Placement, cancellation, reporting, and reminders may answer to different policies and change independently.

Use-case names expose tighter boundaries:

PlaceOrder
CancelOrder
ExportOrders
SendOrderReminder

This does not require one class per route. Combine operations when they share the same policy and dependencies. Split them when changes repeatedly touch unrelated behavior or force consumers to depend on methods they do not use.

Laravel itself is not a responsibility either. A Form Request can own HTTP authorization and input validation because those change with the endpoint contract. The domain must still protect its invariants because jobs and CLI commands can bypass that request.

Tests reveal mixed change boundaries

The original SendInvoice test would need PDF, storage, mail, configuration, an order, and a customer. A failure could come from any of them.

After separation, each decision has evidence at its natural level:

it('applies the billing tax policy', function () {
    $factory = invoiceFactory(taxRate: BasisPoints::of(2550));
    $invoice = $factory->forOrder(anOrder(subtotal: Money::EUR(100_00)));

    expect($invoice->tax)->toEqual(Money::EUR(25_50));
});

it('archives the rendered invoice under its identity', function () {
    Storage::fake('invoices');

    $location = $archive->store($invoiceId, aPdfDocument());

    Storage::disk('invoices')->assertExists($location->path);
});

One application test can prove the collaborators are connected in the right order. It should not repeat every tax and rendering case through a mock choreography.

Test complexity is evidence, not a decomposition algorithm. If every test must construct unrelated infrastructure to exercise one rule, the module may own several change reasons. If extraction creates five mocks for one simple calculation, the new boundaries may be artificial.

Follow the history, not an imagined future

When responsibility is unclear, inspect actual changes. For a real candidate, use its path to find the commits that touched it, then show every path in each of those commits:

git log --follow --format='%H' -- app/Billing/SendInvoice.php |
while read -r commit; do
    git show --format='commit %h %cs %s' --name-only "$commit"
done

Git’s log documentation supports path-limited history, and show exposes the files changed by each selected commit. The output is evidence to inspect, not a cohesion score. A formatter run, namespace rename, dependency upgrade, or broad feature commit can make unrelated files change together. A poor commit can also hide two independent requirements.

For each relevant change, recover the requirement from its issue, pull request, or reviewer context and ask:

  • Which requirements caused this module to change?
  • Which people or business capabilities requested them?
  • Which parts usually change together?
  • Which dependencies are needed by only one subset of behavior?
  • Which tests fail together for unrelated reasons?

Look for repetition rather than one convenient commit. If renderer changes recur without billing changes, while archive changes follow infrastructure work, the history supports separate owners. If they always change for the same invoice requirement, extraction may only spread one responsibility across more files.

Version history and current requirements are stronger evidence than guessing every future variation. They are still incomplete: an old boundary can be wrong even when nobody has changed it recently. Use history to challenge the design, not to replace current domain knowledge. Extract the decision that is already moving independently, protect behavior with tests, and stop when the remaining code is cohesive.

SRP does not demand tiny classes. It demands that a change requested for one reason does not place unrelated behavior at risk. Count change boundaries, not lines, methods, or constructor arguments.