Każdy doświadczony programista Laravel używa wzorców projektowych codziennie - większość z nich bez ich nazywania. Sam framework jest zbudowany na kilku klasycznych wzorcach stosowanych spójnie i elegancko. Rozumienie tego, które wzorce Laravel używa wewnętrznie, dlaczego je stosuje, i które powinieneś świadomie używać we własnym kodzie - to właśnie odróżnia programistów piszących utrzymywalne aplikacje od tych piszących Laravel spaghetti.
📋 Spis treści
- Wzorce Projektowe w Laravel: Te Których Używasz Codziennie (Czy o Tym Wiesz, czy Nie)
🏭 Wzorce używane przez Laravel wewnętrznie
Factory
Zadaniem wzorca Factory jest enkapsulacja tworzenia obiektów, tak aby wywołujący nie musiał wiedzieć, jak obiekt jest budowany. Laravel używa go na dwa różne sposoby.
Model factories do testów:
<?php
// database/factories/OrderFactory.php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Order;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
class OrderFactory extends Factory
{
protected $model = Order::class;
public function definition(): array
{
return [
'user_id' => User::factory(),
'total_cents' => $this->faker->numberBetween(1000, 100000),
'status' => 'pending',
'shipping_method' => $this->faker->randomElement(['standard', 'express']),
];
}
public function completed(): static
{
return $this->state(['status' => 'completed']);
}
public function withHighValue(): static
{
return $this->state(['total_cents' => $this->faker->numberBetween(100000, 1000000)]);
}
}
<?php
// tests/Feature/Orders/OrderReportTest.php
declare(strict_types=1);
namespace Tests\Feature\Orders;
use App\Models\Order;
use Tests\TestCase;
class OrderReportTest extends TestCase
{
public function test_zamowienia_o_wysokiej_wartosci_sa_w_raporcie(): void
{
Order::factory()->count(3)->withHighValue()->completed()->create();
Order::factory()->count(5)->create(); // normalne zamówienia
$response = $this->getJson('/api/v1/reports/high-value-orders');
$response->assertOk()->assertJsonCount(3, 'data');
}
}
Fluent API factory (state() chainowanie) to wzorzec Builder nałożony na Factory - wzorzec wewnątrz wzorca.
Factory oparta na driverach (wzorzec Manager): Cache::driver('redis'), Mail::mailer('ses'), Queue::connection('sqs') - wszystkie używają Manager, który wewnętrznie wywołuje metody fabryczne do tworzenia skonfigurowanych instancji drivera. Dodanie własnego drivera jest proste:
<?php
// app/Providers/AppServiceProvider.php
declare(strict_types=1);
namespace App\Providers;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Cache::extend('dynamodb', function ($app) {
return Cache::repository(new \App\Cache\DynamoDbStore(
$app->make(\Aws\DynamoDb\DynamoDbClient::class)
));
});
}
}
Facade
Facades są powszechnie błędnie rozumiane jako klasy statyczne. Nie są nimi. Facade to proxy delegujące wywołania statyczne do konkretnej instancji rozwiązanej z service container. To rozróżnienie ma znaczenie dla testowania i architektury.
<?php
// Illuminate/Support/Facades/Cache.php (uproszczona koncepcja)
declare(strict_types=1);
namespace Illuminate\Support\Facades;
/**
* @method static mixed get(string $key, mixed $default = null)
* @method static bool put(string $key, mixed $value, int $seconds)
*/
class Cache extends Facade
{
protected static function getFacadeAccessor(): string
{
// Ten string to klucz bindingu w containerze
return 'cache';
}
}
Gdy wywołujesz Cache::get('key'), uruchamia się magia __callStatic PHP, która rozwiązuje app('cache') z containera i wywołuje na nim ->get('key'). Underlying instance to Illuminate\Cache\CacheManager.
Dlaczego to ważne? Bo Cache::fake() podmienia binding w containerze na fałszywą implementację. Gdyby Facades były prawdziwie statycznymi klasami, ich faking byłby niemożliwy bez monkey-patching.
Możesz zbudować własną Facade dla serwisu, do którego chcesz mieć dostęp globalnie:
<?php
// app/Facades/PricingEngine.php
declare(strict_types=1);
namespace App\Facades;
use Illuminate\Support\Facades\Facade;
class PricingEngine extends Facade
{
protected static function getFacadeAccessor(): string
{
return \App\Services\PricingEngine::class;
}
}
Używaj tego oszczędnie - Facades ukrywają zależności, sprawiając że konstruktory są mniej uczciwe wobec tego, czego klasa naprawdę potrzebuje.
Pipeline
Wzorzec Pipeline przekazuje obiekt przez serię transformacji, gdzie każdy etap może zmodyfikować obiekt lub skrócić łańcuch. To fundament systemu middleware HTTP w Laravel.
<?php
// app/Http/Controllers/Api/ReportController.php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Pipelines\Reports\AddFilters;
use App\Pipelines\Reports\AddSorting;
use App\Pipelines\Reports\ApplyDateRange;
use App\Pipelines\Reports\FormatAsCurrency;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Pipeline\Pipeline;
class ReportController extends Controller
{
public function index(Request $request, Pipeline $pipeline): JsonResponse
{
$result = $pipeline
->send($request->all())
->through([
ApplyDateRange::class,
AddFilters::class,
AddSorting::class,
FormatAsCurrency::class,
])
->thenReturn();
return response()->json(['data' => $result]);
}
}
<?php
// app/Pipelines/Reports/ApplyDateRange.php
declare(strict_types=1);
namespace App\Pipelines\Reports;
use Closure;
class ApplyDateRange
{
public function handle(array $payload, Closure $next): array
{
if (isset($payload['from'], $payload['to'])) {
$payload['date_range'] = [
'from' => now()->parse($payload['from'])->startOfDay(),
'to' => now()->parse($payload['to'])->endOfDay(),
];
}
return $next($payload);
}
}
Każdy pipe to mała, jednocelowa klasa. Możesz zmieniać kolejność, dodawać lub usuwać etapy nie dotykając żadnego innego etapu. OCP i SRP wymuszane przez wzorzec.
Observer
Zdarzenia modeli Eloquent implementują wzorzec Observer: gdy stan modelu się zmienia, zarejestrowane observery są automatycznie powiadamiane.
<?php
// app/Observers/OrderObserver.php
declare(strict_types=1);
namespace App\Observers;
use App\Events\OrderCompleted;
use App\Models\Order;
use Illuminate\Support\Facades\Event;
class OrderObserver
{
public function created(Order $order): void
{
\App\Jobs\SendOrderConfirmationJob::dispatch($order);
}
public function updated(Order $order): void
{
if ($order->wasChanged('status') && $order->status === 'completed') {
Event::dispatch(new OrderCompleted($order));
}
}
public function deleting(Order $order): void
{
if ($order->status === 'completed') {
throw new \DomainException('Nie można usunąć ukończonego zamówienia.');
}
}
}
<?php
// app/Providers/AppServiceProvider.php
declare(strict_types=1);
namespace App\Providers;
use App\Models\Order;
use App\Observers\OrderObserver;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Order::observe(OrderObserver::class);
}
}
Observery odsprzęgają efekty uboczne od operacji na modelu. Model Order nic nie wie o powiadomieniach email - po prostu zapisuje. Observer reaguje.
Strategy
Systemy driverów w Laravel to Strategy w działaniu. Cache, queue, filesystem, session, mail - każdy ma zdefiniowany interface i wiele konkretnych implementacji (strategii) wymienialnych przez konfigurację. Gdy wywołujesz Storage::disk('s3')->put(...), FilesystemManager rozwiązuje strategię S3. Przełącz na local w testach - zero zmian w logice aplikacji.
🔨 Wzorce które powinieneś używać w swoim kodzie
Strategy - metody płatności
Wzorzec Strategy definiuje rodzinę algorytmów, enkapsuluje każdy z nich i sprawia, że są wymienne.
<?php
// app/Contracts/PaymentStrategy.php
declare(strict_types=1);
namespace App\Contracts;
use App\ValueObjects\ChargeResult;
interface PaymentStrategy
{
public function charge(int $amountCents, string $currency): ChargeResult;
public function supports(string $method): bool;
}
<?php
// app/Services/Payments/StripeStrategy.php
declare(strict_types=1);
namespace App\Services\Payments;
use App\Contracts\PaymentStrategy;
use App\ValueObjects\ChargeResult;
use Stripe\StripeClient;
class StripeStrategy implements PaymentStrategy
{
public function __construct(private readonly StripeClient $client) {}
public function charge(int $amountCents, string $currency): ChargeResult
{
$intent = $this->client->paymentIntents->create([
'amount' => $amountCents,
'currency' => $currency,
]);
return new ChargeResult($intent->id, $intent->status, $intent->status === 'succeeded');
}
public function supports(string $method): bool
{
return $method === 'stripe';
}
}
<?php
// app/Services/PaymentContext.php
declare(strict_types=1);
namespace App\Services;
use App\Contracts\PaymentStrategy;
use App\ValueObjects\ChargeResult;
use InvalidArgumentException;
class PaymentContext
{
/** @param PaymentStrategy[] $strategies */
public function __construct(private readonly array $strategies) {}
public function charge(string $method, int $amountCents, string $currency): ChargeResult
{
$strategy = $this->findStrategy($method);
return $strategy->charge($amountCents, $currency);
}
private function findStrategy(string $method): PaymentStrategy
{
foreach ($this->strategies as $strategy) {
if ($strategy->supports($method)) {
return $strategy;
}
}
throw new InvalidArgumentException("Brak strategii płatności dla metody: {$method}");
}
}
Decorator - transformacja odpowiedzi
Wzorzec Decorator dodaje zachowanie do obiektu dynamicznie przez owinięcie go, bez zmiany klasy owiniętego obiektu.
<?php
// app/Contracts/QueryExecutor.php
declare(strict_types=1);
namespace App\Contracts;
use Illuminate\Database\Eloquent\Collection;
interface QueryExecutor
{
public function execute(array $filters): Collection;
}
<?php
// app/Repositories/ProductQueryExecutor.php
declare(strict_types=1);
namespace App\Repositories;
use App\Contracts\QueryExecutor;
use App\Models\Product;
use Illuminate\Database\Eloquent\Collection;
class ProductQueryExecutor implements QueryExecutor
{
public function execute(array $filters): Collection
{
return Product::query()
->when($filters['category'] ?? null, fn ($q, $cat) => $q->where('category_id', $cat))
->get();
}
}
<?php
// app/Decorators/CachedQueryExecutor.php
declare(strict_types=1);
namespace App\Decorators;
use App\Contracts\QueryExecutor;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Cache;
class CachedQueryExecutor implements QueryExecutor
{
public function __construct(
private readonly QueryExecutor $inner,
private readonly int $ttlSeconds = 300,
) {}
public function execute(array $filters): Collection
{
$key = 'query_' . md5(serialize($filters));
return Cache::remember($key, $this->ttlSeconds, fn () => $this->inner->execute($filters));
}
}
<?php
// app/Providers/AppServiceProvider.php
declare(strict_types=1);
namespace App\Providers;
use App\Contracts\QueryExecutor;
use App\Decorators\CachedQueryExecutor;
use App\Repositories\ProductQueryExecutor;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(QueryExecutor::class, function (): CachedQueryExecutor {
return new CachedQueryExecutor(
new ProductQueryExecutor(),
ttlSeconds: 300,
);
});
}
}
W testach bindujesz ProductQueryExecutor bezpośrednio, omijając cache. Na produkcji cache owija go transparentnie. Żadna z klas nie została zmodyfikowana.
Builder - budowanie złożonych obiektów
Wzorzec Builder oddziela budowę złożonego obiektu od jego reprezentacji.
<?php
// app/Builders/InvoiceBuilder.php
declare(strict_types=1);
namespace App\Builders;
use App\DataTransferObjects\InvoiceData;
use App\DataTransferObjects\InvoiceLineItem;
use App\Models\Order;
use App\Models\User;
use Carbon\Carbon;
class InvoiceBuilder
{
private User $customer;
private array $lineItems = [];
private ?string $notes = null;
private Carbon $issueDate;
private int $dueDays = 14;
private bool $vatExempt = false;
public function __construct()
{
$this->issueDate = now();
}
public function for(User $customer): static
{
$this->customer = $customer;
return $this;
}
public function fromOrder(Order $order): static
{
foreach ($order->items as $item) {
$this->lineItems[] = new InvoiceLineItem(
description: $item->product->name,
quantity: $item->quantity,
unitPrice: $item->price_cents,
);
}
return $this;
}
public function withNote(string $notes): static
{
$this->notes = $notes;
return $this;
}
public function netTerms(int $days): static
{
$this->dueDays = $days;
return $this;
}
public function vatExempt(): static
{
$this->vatExempt = true;
return $this;
}
public function build(): InvoiceData
{
return new InvoiceData(
customer: $this->customer,
lineItems: $this->lineItems,
issueDate: $this->issueDate,
dueDate: $this->issueDate->copy()->addDays($this->dueDays),
notes: $this->notes,
vatExempt: $this->vatExempt,
);
}
}
<?php
// Użycie w akcji
declare(strict_types=1);
$invoice = (new InvoiceBuilder())
->for($user)
->fromOrder($order)
->withNote('Dziękujemy za zamówienie.')
->netTerms(30)
->vatExempt()
->build();
Eloquent query builder podąża dokładnie tym wzorcem: User::where()->with()->orderBy()->paginate(). Builder akumuluje ograniczenia i wykonuje SQL dopiero gdy wywołasz metodę terminalną.
Null Object - eliminacja defensywnych sprawdzeń null
Wzorzec Null Object dostarcza domyślny obiekt, który nic nie robi, zamiast referencji null, która powoduje TypeError lub wymusza defensywne sprawdzenia wszędzie.
<?php
// Bez Null Object - defensywne sprawdzenia wszędzie:
if ($user->profile !== null && $user->profile->avatar_url !== null) {
$avatar = $user->profile->avatar_url;
} else {
$avatar = '/images/default-avatar.png';
}
<?php
// app/Models/NullUserProfile.php
declare(strict_types=1);
namespace App\Models;
class NullUserProfile extends UserProfile
{
public string $bio = '';
public string $avatar_url = '/images/default-avatar.png';
public string $website = '';
public function save(array $options = []): bool
{
// Null obiekty nie mogą się persistować
return true;
}
}
<?php
// app/Models/User.php (fragment dotyczący profilu)
declare(strict_types=1);
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
public function profile(): \Illuminate\Database\Eloquent\Relations\HasOne
{
return $this->hasOne(UserProfile::class);
}
public function getProfileAttribute(): UserProfile
{
return $this->relations['profile'] ?? new NullUserProfile();
}
}
<?php
// Teraz czysty kod wszędzie - bez defensywnych sprawdzeń:
$avatar = $user->profile->avatar_url; // Zawsze bezpieczne, zwraca domyślny jeśli brak profilu
$bio = $user->profile->bio; // Pusty string, nie null
🚫 Wzorce których nie warto nadużywać
Repository pattern jako wrapper Eloquenta. Najbardziej cargo-cultowany wzorzec w Laravel. Gdy twój UserRepository to tylko User::find($id), User::create($data), User::where('email', $email)->first() - dodajesz warstwę pośrednią bez żadnej korzyści. Eloquent jest już twoją abstrakcją persistence. Repository ma sens gdy masz prawdziwą złożoność domenową, wiele źródeł danych lub potrzebujesz in-memory test doubles. Bez tych presji to tylko ceremonia.
Singleton z globalnym mutowalnym stanem. PHP-FPM tworzy nowy proces na żądanie, więc singletony są ograniczone do jednego requestu. W Octane (persistentne procesy), singleton akumulujący stan między requestami to błąd concurrency czekający na ujawnienie. Singletony powinny być bezstanowymi serwisami, nie pojemnikami na stan.
Łańcuchy event-listener dla sekwencyjnej logiki. Wystrzelenie eventu z listenera, który wystrzeliwuje kolejny event ze swojego listenera, tworzy niewidoczny łańcuch wywołań niemożliwy do śledzenia w debuggerze. Użyj orkiestrującej Action lub Pipeline dla operacji sekwencyjnych. Eventy służą do rozgłaszania faktu - "coś się wydarzyło" - nie do chainowania logiki.
Abstract Factory dla jednego produktu. Budowanie AbstractFactory z createButton(), createInput(), createModal() dla systemu, który będzie miał dokładnie jeden motyw UI, to over-engineering. Zacznij od konkretnych klas. Wprowadź abstrakcję gdy pojawi się drugi wariant.
- Sam Laravel to podręcznik wzorców projektowych: Factory, Facade, Pipeline, Observer i Strategy są wbudowane w framework
- Facades nie są klasami statycznymi - to proxy rozwiązywane przez container, dlatego
Facade::fake()działa - Strategy i Decorator są dwoma najbardziej praktycznie użytecznymi wzorcami dla kodu aplikacji Laravel
- Wzorzec Null Object eliminuje całe kategorie błędów null-check i sprawia, że kod jest bardziej czytelny
- Wzorzec Builder świetnie sprawdza się do budowania złożonych obiektów danych z opcjonalnymi polami
- Repository jako cienki wrapper Eloquenta to ceremonia, nie architektura - dodaj go tylko gdy złożoność to uzasadnia
- Singletony w Octane muszą być bezstanowe; eventy służą do rozgłaszania faktów, nie do chainowania logiki biznesowej
Obserwuj mnie na LinkedIn po więcej wskazówek o Laravel!