SOLID is a vocabulary for change pressure. Single responsibility means an action has one reason to change. Open/closed means add a pricing rule through a strategy rather than edit a central conditional. Dependency inversion means depend on a meaningful contract where multiple implementations or tests need it—not make interfaces for every model.
Laravel’s container resolves constructor dependencies, but it does not automatically make a design modular. Prefer concrete classes until a real seam appears, keep contracts narrow, and prove the before/after code with a test. The goal is easier change, not a taller folder tree.
One responsibility means one kind of change
Keep controllers as HTTP adapters. Put a reusable use case in a typed action so it can run from an API, command or job without recreating HTTP state.
<?php
declare(strict_types=1);
final readonly class CreateInvoice
{
public function __construct(private InvoiceNotifier $notifier) {}
public function handle(User $user, int $amount): Invoice
{
$invoice = Invoice::query()->create(['user_id' => $user->getKey(), 'amount' => $amount]);
$this->notifier->sendCreated($invoice);
return $invoice;
}
}
Extend variation; do not centralise it
A match is right for two stable cases. Extract a contract when behaviour arrives independently, so each rule is testable without the others.
<?php
declare(strict_types=1);
interface TaxCalculator
{
public function calculate(int $netCents): int;
}
final readonly class PolishTaxCalculator implements TaxCalculator
{
public function calculate(int $netCents): int
{
return intdiv($netCents * 23, 100);
}
}
What the container actually does for DIP
The container resolves constructor dependencies; it does not select a runtime implementation merely because it sees an interface. Bind a default implementation when every consumer needs it. Use contextual binding only for a known consumer, and a resolver or factory when the selection depends on tenant, country or request data.
<?php
declare(strict_types=1);
use Illuminate\Support\ServiceProvider;
final class BillingServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(TaxCalculator::class, PolishTaxCalculator::class);
}
}
app(TaxCalculator::class) now resolves because it has a concrete binding. Do not inject the container into domain code to defer the decision; inject a TaxCalculatorResolver when selection is runtime data.
LSP, ISP and when not to force SOLID
Do not make ReadOnlyStorage implement Storage if delete() must throw: split the capability into read() and write() contracts. Equally, do not create an interface for one stable Eloquent repository or a one-off action. Add a boundary for changing behaviour, an external dependency to fake, or an independent policy. The alternative is a focused concrete class, not tangled code.