SOLID w Laravel: Realne Transformacje Kodu Przed i Po dla Każdej Zasady

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)

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!

Komentarze (0)
Zostaw komentarz

© 2026 Wszelkie prawa zastrzeżone.