Zasady SOLID są przywoływane na każdej rozmowie rekrutacyjnej i w niemal każdym code review, a mimo to większość projektów Laravel narusza co najmniej trzy z nich zanim trafi na produkcję. To nie jest traktat teoretyczny - to przewodnik po prawdziwym kodzie z przykładami transformacji przed/po, które możesz rozpoznać we własnych projektach. Każda zasada jest zilustrowana konkretną zmianą: kod który znasz, kod który możesz wysyłać na produkcję.
📋 Spis treści
- 🔴 Single Responsibility Principle (SRP)
- 🟠 Open/Closed Principle (OCP)
- 🟡 Liskov Substitution Principle (LSP)
- 🟢 Interface Segregation Principle (ISP)
- 🔵 Dependency Inversion Principle (DIP)
- 🏗️ Jak Laravel Container wspiera SOLID
- ⚠️ Najczęstsze naruszenia SOLID w projektach Laravel
- 🤔 Kiedy NIE stosować SOLID na siłę
🔴 Single Responsibility Principle (SRP)
Klasa powinna mieć tylko jeden powód do zmiany.
PRZED - antywzorzec fat controllera:
<?php
// app/Http/Controllers/Api/OrderController.php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Models\Order;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class OrderController extends Controller
{
public function store(Request $request): JsonResponse
{
// Walidacja wmieszana w kontroler
$request->validate([
'items' => 'required|array|min:1',
'items.*.id' => 'required|integer|exists:products,id',
'items.*.qty' => 'required|integer|min:1',
'shipping_method' => 'required|in:standard,express',
'coupon_code' => 'nullable|string',
]);
// Logika biznesowa w kontrolerze
$total = 0;
foreach ($request->items as $item) {
$product = \App\Models\Product::find($item['id']);
$total += $product->price_cents * $item['qty'];
}
if ($request->coupon_code) {
$coupon = \App\Models\Coupon::where('code', $request->coupon_code)->firstOrFail();
$total = (int) ($total * (1 - $coupon->discount_rate));
}
$order = Order::create([
'user_id' => auth()->id(),
'total_cents' => $total,
'shipping_method' => $request->shipping_method,
'status' => 'pending',
]);
foreach ($request->items as $item) {
$order->items()->create([
'product_id' => $item['id'],
'quantity' => $item['qty'],
'price_cents' => \App\Models\Product::find($item['id'])->price_cents,
]);
}
// Powiadomienia w kontrolerze
\Mail::to(auth()->user())->send(new \App\Mail\OrderConfirmation($order));
return response()->json(['data' => $order->load('items')], 201);
}
}
Ten kontroler ma co najmniej cztery powody do zmiany: reguły walidacji się zmieniają, logika cenowa się zmienia, strategia powiadomień się zmienia, format odpowiedzi się zmienia. Ukrywa też zapytania N+1 w pętli.
PO - rozdzielone odpowiedzialności:
<?php
// app/Http/Requests/Api/StoreOrderRequest.php
declare(strict_types=1);
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest;
class StoreOrderRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'items' => ['required', 'array', 'min:1'],
'items.*.id' => ['required', 'integer', 'exists:products,id'],
'items.*.qty' => ['required', 'integer', 'min:1'],
'shipping_method' => ['required', 'in:standard,express'],
'coupon_code' => ['nullable', 'string', 'exists:coupons,code'],
];
}
}
<?php
// app/Actions/Orders/CreateOrderAction.php
declare(strict_types=1);
namespace App\Actions\Orders;
use App\DataTransferObjects\CreateOrderData;
use App\Models\Coupon;
use App\Models\Order;
use App\Models\Product;
use Illuminate\Support\Facades\DB;
class CreateOrderAction
{
public function execute(CreateOrderData $data): Order
{
return DB::transaction(function () use ($data): Order {
$products = Product::findMany(
collect($data->items)->pluck('id')
)->keyBy('id');
$total = collect($data->items)->sum(
fn ($item) => $products[$item['id']]->price_cents * $item['qty']
);
if ($data->couponCode) {
$coupon = Coupon::where('code', $data->couponCode)->firstOrFail();
$total = (int) ($total * (1 - $coupon->discount_rate));
}
$order = Order::create([
'user_id' => $data->userId,
'total_cents' => $total,
'shipping_method' => $data->shippingMethod,
'status' => 'pending',
]);
$order->items()->createMany(
collect($data->items)->map(fn ($item) => [
'product_id' => $item['id'],
'quantity' => $item['qty'],
'price_cents' => $products[$item['id']]->price_cents,
])->all()
);
return $order->load('items');
});
}
}
<?php
// app/Http/Controllers/Api/OrderController.php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Actions\Orders\CreateOrderAction;
use App\DataTransferObjects\CreateOrderData;
use App\Http\Requests\Api\StoreOrderRequest;
use App\Http\Resources\OrderResource;
use Illuminate\Http\JsonResponse;
class OrderController extends Controller
{
public function store(
StoreOrderRequest $request,
CreateOrderAction $action,
): JsonResponse {
$order = $action->execute(
CreateOrderData::fromRequest($request)
);
return response()->json(['data' => new OrderResource($order)], 201);
}
}
Kontroler ma teraz jedno zadanie: przetłumacz żądanie HTTP na wywołanie akcji i zwróć odpowiedź. Zmiana formatu odpowiedzi? Tylko Resource. Zmiana logiki cenowej? Tylko Action. Zmiana walidacji? Tylko FormRequest.
🟠 Open/Closed Principle (OCP)
Encje programistyczne powinny być otwarte na rozszerzenie, zamknięte na modyfikację.
PRZED - koszmar if/else dla powiadomień:
<?php
// app/Services/NotificationService.php
declare(strict_types=1);
namespace App\Services;
use App\Models\User;
class NotificationService
{
public function sendOrderConfirmation(User $user, array $orderData): void
{
if ($user->notification_channel === 'email') {
\Mail::to($user->email)->send(new \App\Mail\OrderConfirmation($orderData));
} elseif ($user->notification_channel === 'sms') {
\Twilio::message($user->phone, 'Zamówienie #' . $orderData['id'] . ' potwierdzone.');
} elseif ($user->notification_channel === 'push') {
\Firebase::send($user->push_token, ['title' => 'Zamówienie potwierdzone']);
}
// Dodajesz Slack? Modyfikujesz tę klasę za każdym razem.
}
}
PO - interface i implementacje:
<?php
// app/Contracts/NotificationChannel.php
declare(strict_types=1);
namespace App\Contracts;
use App\Models\User;
interface NotificationChannel
{
public function send(User $user, string $subject, string $message): void;
public function supports(string $channelName): bool;
}
<?php
// app/Notifications/Channels/EmailChannel.php
declare(strict_types=1);
namespace App\Notifications\Channels;
use App\Contracts\NotificationChannel;
use App\Mail\GenericNotificationMail;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
class EmailChannel implements NotificationChannel
{
public function send(User $user, string $subject, string $message): void
{
Mail::to($user->email)->send(new GenericNotificationMail($subject, $message));
}
public function supports(string $channelName): bool
{
return $channelName === 'email';
}
}
<?php
// app/Services/NotificationService.php
declare(strict_types=1);
namespace App\Services;
use App\Contracts\NotificationChannel;
use App\Models\User;
use RuntimeException;
class NotificationService
{
/** @param NotificationChannel[] $channels */
public function __construct(private readonly array $channels) {}
public function sendOrderConfirmation(User $user, array $orderData): void
{
$channel = $this->resolveChannel($user->notification_channel);
$channel->send(
$user,
'Zamówienie potwierdzone',
"Twoje zamówienie #{$orderData['id']} zostało potwierdzone."
);
}
private function resolveChannel(string $name): NotificationChannel
{
foreach ($this->channels as $channel) {
if ($channel->supports($name)) {
return $channel;
}
}
throw new RuntimeException("Brak handlera dla kanału: {$name}");
}
}
Dodanie kanału Slack to teraz stworzenie SlackChannel implements NotificationChannel i zarejestrowanie go - zero zmian w istniejącym kodzie.
🟡 Liskov Substitution Principle (LSP)
Podtypy muszą być substytucyjne dla swoich typów bazowych bez naruszania poprawności programu.
PRZED - klasa potomna łamiąca kontrakt:
<?php
// app/Services/PaymentProcessor.php
declare(strict_types=1);
namespace App\Services;
class PaymentProcessor
{
public function charge(int $amountCents, string $currency): array
{
// Zwraca ['transaction_id' => '...', 'status' => 'success']
return ['transaction_id' => 'txn_123', 'status' => 'success'];
}
}
class FreeTrialPaymentProcessor extends PaymentProcessor
{
public function charge(int $amountCents, string $currency): bool
{
// NARUSZENIE LSP: zmieniony typ zwracany - kod oczekujący tablicy się posypie
return true;
}
}
class RefundOnlyProcessor extends PaymentProcessor
{
public function charge(int $amountCents, string $currency): array
{
// NARUSZENIE LSP: wyjątek, którego rodzic nigdy nie obiecał
throw new \BadMethodCallException('Ten procesor nie obsługuje obciążeń.');
}
}
PO - poprawna hierarchia przez interfejsy:
<?php
// app/Contracts/ChargeableGateway.php
declare(strict_types=1);
namespace App\Contracts;
use App\ValueObjects\ChargeResult;
interface ChargeableGateway
{
public function charge(int $amountCents, string $currency): ChargeResult;
}
<?php
// app/ValueObjects/ChargeResult.php
declare(strict_types=1);
namespace App\ValueObjects;
final readonly class ChargeResult
{
public function __construct(
public readonly string $transactionId,
public readonly string $status,
public readonly bool $success,
) {}
}
<?php
// app/Services/Gateways/FreeTrialGateway.php
declare(strict_types=1);
namespace App\Services\Gateways;
use App\Contracts\ChargeableGateway;
use App\ValueObjects\ChargeResult;
class FreeTrialGateway implements ChargeableGateway
{
public function charge(int $amountCents, string $currency): ChargeResult
{
return new ChargeResult('free_trial_' . uniqid(), 'success', true);
}
}
Każda implementacja honoruje ten sam kontrakt. Możesz podmieniać gateway'e - kod kliencki nigdy się nie posypie.
🟢 Interface Segregation Principle (ISP)
Klienci nie powinni być zmuszeni do zależności od interfejsów, których nie używają.
PRZED - rozdęty interface repozytorium:
<?php
// app/Contracts/UserRepository.php
declare(strict_types=1);
namespace App\Contracts;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
interface UserRepository
{
public function findById(int $id): ?User;
public function findByEmail(string $email): ?User;
public function findByApiToken(string $token): ?User;
public function save(User $user): void;
public function delete(int $id): void;
public function paginate(int $perPage): LengthAwarePaginator;
public function countByRole(string $role): int;
public function bulkUpdateStatus(array $ids, string $status): int;
public function exportToCsv(): string;
public function importFromCsv(string $path): int;
}
AuthService potrzebujący tylko findByEmail jest zmuszony zależeć od interfejsu ciągnącego za sobą exportToCsv i bulkUpdateStatus.
PO - małe, skupione interfejsy:
<?php
// app/Contracts/Users/CanFindUser.php
declare(strict_types=1);
namespace App\Contracts\Users;
use App\Models\User;
interface CanFindUser
{
public function findById(int $id): ?User;
public function findByEmail(string $email): ?User;
}
<?php
// app/Contracts/Users/CanPersistUser.php
declare(strict_types=1);
namespace App\Contracts\Users;
use App\Models\User;
interface CanPersistUser
{
public function save(User $user): void;
public function delete(int $id): void;
}
<?php
// app/Contracts/Users/CanQueryUsers.php
declare(strict_types=1);
namespace App\Contracts\Users;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
interface CanQueryUsers
{
public function paginate(int $perPage): LengthAwarePaginator;
public function countByRole(string $role): int;
}
<?php
// app/Repositories/EloquentUserRepository.php
declare(strict_types=1);
namespace App\Repositories;
use App\Contracts\Users\CanFindUser;
use App\Contracts\Users\CanPersistUser;
use App\Contracts\Users\CanQueryUsers;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class EloquentUserRepository implements CanFindUser, CanPersistUser, CanQueryUsers
{
public function findById(int $id): ?User
{
return User::find($id);
}
public function findByEmail(string $email): ?User
{
return User::where('email', $email)->first();
}
public function save(User $user): void
{
$user->save();
}
public function delete(int $id): void
{
User::destroy($id);
}
public function paginate(int $perPage): LengthAwarePaginator
{
return User::paginate($perPage);
}
public function countByRole(string $role): int
{
return User::role($role)->count();
}
}
<?php
// app/Services/AuthService.php
declare(strict_types=1);
namespace App\Services;
use App\Contracts\Users\CanFindUser;
// AuthService zależy tylko od tego, czego naprawdę potrzebuje
class AuthService
{
public function __construct(private readonly CanFindUser $users) {}
public function attemptByEmail(string $email, string $password): bool
{
$user = $this->users->findByEmail($email);
// ...
return false;
}
}
🔵 Dependency Inversion Principle (DIP)
Moduły wysokiego poziomu nie powinny zależeć od modułów niskiego poziomu. Oba powinny zależeć od abstrakcji.
PRZED - zakodowana konkretna zależność:
<?php
// app/Actions/Payments/ChargeCustomerAction.php
declare(strict_types=1);
namespace App\Actions\Payments;
use App\Services\Gateways\StripeGateway;
class ChargeCustomerAction
{
public function execute(int $userId, int $amountCents): array
{
// NARUSZENIE DIP: bezpośrednia zależność od StripeGateway
$gateway = new StripeGateway(config('services.stripe.secret'));
return $gateway->charge($amountCents, 'usd');
}
}
Testowanie wymaga prawdziwego połączenia ze Stripe lub kruchego mockowania operatora new.
PO - zależność od abstrakcji, wstrzyknięcie konkretu:
<?php
// app/Contracts/PaymentGateway.php
declare(strict_types=1);
namespace App\Contracts;
use App\ValueObjects\ChargeResult;
interface PaymentGateway
{
public function charge(int $amountCents, string $currency): ChargeResult;
public function refund(string $transactionId, int $amountCents): ChargeResult;
}
<?php
// app/Actions/Payments/ChargeCustomerAction.php
declare(strict_types=1);
namespace App\Actions\Payments;
use App\Contracts\PaymentGateway;
use App\Models\Payment;
use App\ValueObjects\ChargeResult;
class ChargeCustomerAction
{
public function __construct(private readonly PaymentGateway $gateway) {}
public function execute(int $userId, int $amountCents, string $currency = 'usd'): ChargeResult
{
$result = $this->gateway->charge($amountCents, $currency);
Payment::create([
'user_id' => $userId,
'amount_cents' => $amountCents,
'transaction_id' => $result->transactionId,
'status' => $result->status,
]);
return $result;
}
}
<?php
// app/Providers/PaymentServiceProvider.php
declare(strict_types=1);
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\Gateways\StripeGateway;
use Illuminate\Support\ServiceProvider;
use Stripe\StripeClient;
class PaymentServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(PaymentGateway::class, function (): StripeGateway {
return new StripeGateway(
new StripeClient(config('services.stripe.secret'))
);
});
}
}
W testach bindujemy FakePaymentGateway lub używamy $this->mock(PaymentGateway::class). Na produkcji zmieniamy na PayPalGateway jedną linią w providerze.
🏗️ Jak Laravel Container wspiera SOLID
Laravel service container to silnik, który sprawia, że SOLID w PHP jest praktyczne, a nie tylko aspiracyjne.
Automatyczna rezolucja (autowiring) - Type-hintujesz interface w konstruktorze, container rozwiązuje konkretną klasę związaną z tym interfejsem. Zero ręcznego okablowania.
Singleton vs transient bindings - singleton() współdzieli jedną instancję przez cały request. bind() tworzy nową instancję przy każdej rezolucji. Właściwy wybór jest częścią SRP: bezstanowy serwis powinien być singletonem; stanowy builder - transientem.
Contextual binding - Gdy dwie klasy potrzebują tego samego interfejsu, ale różnych implementacji:
<?php
// app/Providers/AppServiceProvider.php
declare(strict_types=1);
namespace App\Providers;
use App\Actions\Payments\ChargeCustomerAction;
use App\Actions\Subscriptions\ChargeSubscriptionAction;
use App\Contracts\PaymentGateway;
use App\Services\Gateways\PayPalGateway;
use App\Services\Gateways\StripeGateway;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app
->when(ChargeCustomerAction::class)
->needs(PaymentGateway::class)
->give(StripeGateway::class);
$this->app
->when(ChargeSubscriptionAction::class)
->needs(PaymentGateway::class)
->give(PayPalGateway::class);
}
}
OCP i DIP wymuszane na poziomie containera - zero if/else w logice biznesowej.
⚠️ Najczęstsze naruszenia SOLID w projektach Laravel
1. Joby które robią wszystko. ProcessOrder job który waliduje, pobiera płatność, wysyła email, aktualizuje stany magazynowe i wyzwala zdarzenia analityczne to naruszenie SRP na warstwie kolejki. Podziel na ChargeOrderJob, SendConfirmationJob, powiązane łańcuchowo lub dispatchowane z orkiestrującej akcji.
2. Modele Eloquent z logiką biznesową. Dodawanie calculateDiscount(), sendWelcomeEmail() i generateInvoicePdf() do modelu User daje mu cztery powody do zmiany. Modele powinny obsługiwać dostęp do atrybutów, relacje i casty - nic więcej.
3. Traits jako współdzielone dziedziczenie. PHP traits to kopiuj-wklej w czasie kompilacji. Wrzucanie logiki zapytań do traitu HasFilters używanego w 15 modelach to naruszenie ISP - każdy model dziedziczy każdą metodę filtrowania nawet jeśli używa dwóch.
4. Facades ukrywające zależności. Cache::get() i Mail::send() wewnątrz metod sprawiają, że te metody są trudne do testowania bez Facade::fake() i ukrywają graf zależności. Wstrzykuj CacheRepository i Mailer przez konstruktory - zależności są wtedy jawne.
5. Tablice konfiguracyjne jako pseudo-strategia. config/payments.php z logiką warunkową sprawdzającą APP_ENV to naruszenie OCP w konfiguracji. Użyj contextual binding lub tagged services.
🤔 Kiedy NIE stosować SOLID na siłę
SOLID to zestaw celów projektowych, nie przykazań. Ślepe stosowanie tworzy własne problemy.
Przedwczesna abstrakcja zabija szybkość. Budowanie pełnego interfejsu-plus-implementacji dla systemu powiadomień, który zawsze będzie wysyłał tylko email, dodaje pośrednictwo bez korzyści. Dodaj abstrakcję, gdy pojawi się druga implementacja.
Proste CRUD nie potrzebuje akcji. TagController który listuje, tworzy, aktualizuje i usuwa tagi bez żadnej logiki biznesowej nie zyska na UpdateTagAction. Zostaw to w kontrolerze. SRP na poziomie frameworka (FormRequest + Resource) wystarczy.
Nadmiernie podzielone interfejsy stają się szumem. Jeśli twój interface ma jedną metodę, zapytaj czy w ogóle potrzebny jest interface. CanCalculateTax z pojedynczą metodą calculate() używaną w jednym miejscu to pośrednictwo bez konsumenta. Closure lub konkretna klasa serwisu jest czystsza.
Testowalność to gwiazda północna. Stosuj SOLID gdy sprawia, że klasa jest łatwiejsza do testowania w izolacji lub łatwiejsza do rozszerzenia bez ryzyka regresji. Jeśli żadne z nich nie jest prawdą dla twojego przypadku użycia - pomiń. Celem jest utrzymywalny, dostarczalny software - nie architektoniczna czystość.
- SOLID to słownik do opisywania problemów z powiązaniami, nie przepis do mechanicznego stosowania
- SRP jest najczęściej naruszane w kontrolerach i jobach - FormRequests, Actions i Resources rozwiązują to elegancko w Laravel
- OCP i DIP najlepiej implementować razem: zdefiniuj interface, zbinduj kontrety w service providerze, wstrzykuj interface wszędzie
- LSP łamie się gdy klasy potomne zmieniają typy zwracane lub rzucają wyjątki, których rodzic nigdy nie zadeklarował
- ISP utrzymuje twoje test double małymi, a konstruktory uczciwe
- Laravel service container sprawia, że DIP jest prawie darmowe: type-hintnij interface i pozwól containerowi go okablować
- Stosuj SOLID gdy redukuje coupling lub poprawia testowalność; pomijaj gdy dodaje pośrednictwo bez korzyści
Obserwuj mnie na LinkedIn po więcej wskazówek o Laravel! Chciałbyś dowiedzieć się więcej o stosowaniu tych zasad w prawdziwym projekcie DDD z Laravel? Daj znać w komentarzach!