Rozszerzanie Laravela w zalecany sposób: Macra, drivery i dyrektywy

11 min. czytania

Laravel jest celowo rozszerzalny, ale nie oznacza to, że każde rozszerzenie ma sens. Helper dodany do fasady może być niegroźny. Ta sama wygoda osiągnięta przez podmianę klasy frameworka w vendor/, nadpisanie wewnętrznej metody albo rozrzucenie wywołań app() po kontrolerach staje się długiem utrzymaniowym przy najbliższej aktualizacji.

Użyteczne pytanie nie brzmi: „jak sprawić, aby Laravel to zrobił?”. Brzmi: „który publiczny punkt rozszerzenia już opisuje taki rodzaj zmiany?”. Macro dodaje małą lokalną wygodę do fluent API. Własna reguła walidacji wyraża wiedzę domenową. Komponent Blade lub dyrektywa zmienia składnię prezentacji. Decorator dodaje zachowanie wokół kontraktu aplikacji. Manager obsługuje nazwaną rodzinę driverów wybieranych w runtime. Komendy są jawnym interfejsem dla operatora.

W tym artykule budujemy mały subsystem dostarczania wiadomości i używamy go do wybrania właściwych punktów rozszerzenia. Celem nie jest katalog wszystkich hooków Laravela. Chodzi o to, aby kolejny developer umiał znaleźć, przetestować, skonfigurować i w razie potrzeby usunąć rozszerzenie bez odtwarzania w głowie wewnętrznych mechanizmów frameworka.

Zacznij od zmiany, nie od hooka

W projektach Laravel często powtarza się antywzorzec: zespół widzi znajomą fasadę i dodaje macro, bo to najkrótsza droga do wyniku. Macro zaczyna potem czytać konfigurację, wykonywać HTTP I/O, otwierać transakcję bazy danych i znać uwierzytelnionego użytkownika. Przestaje być metodą wygody; staje się ukrytym zachowaniem aplikacji podpiętym pod globalną nazwę.

PotrzebaNatywny punkt rozszerzeniaZachowaj mały zakres przez
Ponownie używana transformacja fluentmacro lub mixinprzyjmowanie i zwracanie wartości
Ponownie używana walidacja biznesowaobiekt regułyjedną jasną porażkę walidacji
Powtarzalna składnia widokunajpierw komponent, potem dyrektywaniewkładanie pracy do szablonów
Logowanie, cache, metryki wokół serwisudecorator przez extend() kontenerazależność od własnego kontraktu, nie fasady
Konfigurowany wybór transportuManager i driverytraktowanie nazw driverów jako konfiguracji
Świadome zadanie operacyjnekomenda Artisanjawne i obserwowalne skutki uboczne

To rozróżnienie ma znaczenie, ponieważ każdy punkt ma inny cykl życia. Macra są globalnym stanem procesu. Rozwiązany manager cache'uje instancje driverów. Decorator zmienia każde rozwiązanie kontraktu. Komenda musi poprawnie działać, gdy uruchamia ją scheduler, człowiek albo skrypt wdrożeniowy. Zły wybór tworzy błędy widoczne dopiero przy kolejkach, workerach Octane, cache'owanej konfiguracji albo przyszłej aktualizacji paczki.

Macro poprawia słownictwo, nie zastępuje warstwy serwisów

Załóżmy, że identyfikatory partnerów przychodzące z zewnątrz wymagają spójnej postaci kanonicznej. To czysta transformacja tekstu bez współpracowników. Macro Str pasuje, bo miejsce wywołania pozostaje czytelne, a implementacja nie ma ukrytego I/O.

php
<?php

declare(strict_types=1);

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;

final class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
    }

    public function boot(): void
    {
        Str::macro('partnerReference', static function (string $value): string {
            $normalized = Str::upper(Str::replaceMatches('/[^A-Za-z0-9]+/', '-', $value));

            return Str::trim($normalized, '-');
        });
    }
}

Macro jest rejestrowane raz podczas startu aplikacji, a potem dostępne jako Str::partnerReference('acme / 42'). Zwraca wartość, nie ma stanu requestu i da się je sprawdzić małym testem.

php
<?php

declare(strict_types=1);

use Illuminate\Support\Str;

it('normalizes a partner reference', function (): void {
    expect(Str::partnerReference(' acme / north 42 '))->toBe('ACME-NORTH-42');
});

Ten test ujawnia także globalny charakter macro. Rejestruj tutaj tylko stabilne zachowanie: nazwa macro jest w praktyce publicznym API wewnątrz aplikacji. Nie wkładaj do closure wartości zależnych od tenantów, uwierzytelnionego użytkownika ani serwisu związanego z requestem. Długowieczne workery mogą zachować taki stan długo po zakończeniu requestu, który go utworzył.

Mixin pomaga, gdy kilka transformacji tworzy jeden spójny język. Nie rozwiązuje problemu projektowego wyłącznie przez przeniesienie macro do innej klasy. Jeśli kod potrzebuje repozytorium, klienta HTTP, retry lub feature flaga, nazwany serwis wstrzykiwany przez kontener będzie czytelniejszy.

Umieść walidację domenową w obiekcie reguły

Walidacja to kolejne miejsce, w którym closure wydaje się kuszące. Closure wystarcza raz. Obiekt reguły wygrywa, gdy istotne są jego wyjaśnienie, testy albo zależności. W tym przykładzie zamówienie można wysłać do dostawcy tylko, gdy jego wartość przekracza skonfigurowane minimum.

php
<?php

declare(strict_types=1);

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

final readonly class MeetsDeliveryMinimum implements ValidationRule
{
    public function __construct(
        private int $minimumCents,
    ) {
    }

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (filter_var($value, FILTER_VALIDATE_INT) === false || (int) $value < $this->minimumCents) {
            $fail("The {$attribute} must be at least {$this->minimumCents} cents.");
        }
    }
}

Request pozostaje deklaratywny, a polityka ma jeden łatwy do znalezienia dom. Przykład zakłada import Delivery z App\Models.

php
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Models\Delivery;
use App\Rules\MeetsDeliveryMinimum;
use Illuminate\Foundation\Http\FormRequest;

final class CreateDeliveryRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()?->can('create', Delivery::class) ?? false;
    }

    /** @return array<string, list<MeetsDeliveryMinimum|string>> */
    public function rules(): array
    {
        return [
            'amount_cents' => ['required', 'integer', new MeetsDeliveryMinimum(500)],
        ];
    }
}

Nie zamieniaj małej reguły w silnik zapytań. Reguła walidacji może zadać jednemu skupionemu współpracownikowi jedno pytanie, ale autoryzacja należy do policy, a wieloetapowe decyzje biznesowe do akcji lub domain service'u.

Dekoruj kontrakt, który należy do aplikacji

Decorator jest dobrym rozwiązaniem, gdy sama operacja się nie zmienia, ale każdy wywołujący ma zyskać przekrojową funkcję, na przykład logowanie, metryki albo cache. Ważną granicą jest kontrakt aplikacji, nie konkretna klasa z Illuminate. To daje stabilny punkt do testowania i podmiany.

php
<?php

declare(strict_types=1);

namespace App\Delivery;

final readonly class DeliveryMessage
{
    public function __construct(
        public string $recipient,
        public string $body,
        public string $reference,
    ) {
    }
}
php
<?php

declare(strict_types=1);

namespace App\Delivery;

interface DeliveryChannel
{
    public function send(DeliveryMessage $message): void;
}

Lokalny driver także jest celowo kompletny. Pozwala weryfikować działanie lokalnie i operacyjnie bez konta u prawdziwego dostawcy.

php
<?php

declare(strict_types=1);

namespace App\Delivery;

use Psr\Log\LoggerInterface;

final readonly class LogDeliveryChannel implements DeliveryChannel
{
    public function __construct(
        private LoggerInterface $logger,
    ) {
    }

    public function send(DeliveryMessage $message): void
    {
        $this->logger->info('Delivery message accepted.', [
            'reference' => $message->reference,
            'recipient_type' => str($message->recipient)->before(':')->toString(),
        ]);
    }
}
php
<?php

declare(strict_types=1);

namespace App\Delivery;

use Illuminate\Http\Client\Factory as HttpFactory;

final readonly class WebhookDeliveryChannel implements DeliveryChannel
{
    public function __construct(
        private HttpFactory $http,
        private string $endpoint,
        private string $token,
    ) {
    }

    public function send(DeliveryMessage $message): void
    {
        $this->http
            ->acceptJson()
            ->withToken($this->token)
            ->connectTimeout(2)
            ->timeout(5)
            ->retry([100, 250], throw: false)
            ->post($this->endpoint, [
                'recipient' => $message->recipient,
                'body' => $message->body,
                'reference' => $message->reference,
            ])
            ->throw();
    }
}

Teraz dodaj logowanie bez zmiany wywołujących ani transportu:

php
<?php

declare(strict_types=1);

namespace App\Delivery;

use Psr\Log\LoggerInterface;

final readonly class LoggedDeliveryChannel implements DeliveryChannel
{
    public function __construct(
        private DeliveryChannel $inner,
        private LoggerInterface $logger,
    ) {
    }

    public function send(DeliveryMessage $message): void
    {
        $this->logger->info('Sending delivery message.', [
            'reference' => $message->reference,
            'channel' => $this->inner::class,
        ]);

        $this->inner->send($message);
    }
}

Zbindowanie konkretnej implementacji, a potem rozszerzenie jej kontraktu w providerze, nie zmienia kodu klientów. Closure uruchamia się, gdy kontener rozwiązuje binding, dlatego musi zwrócić ten sam kontrakt.

php
<?php

declare(strict_types=1);

namespace App\Providers;

use App\Delivery\DeliveryChannel;
use App\Delivery\LoggedDeliveryChannel;
use App\Delivery\WebhookDeliveryChannel;
use Illuminate\Contracts\Container\Container;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;
use Psr\Log\LoggerInterface;

final class DeliveryServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(DeliveryChannel::class, function (Application $app): DeliveryChannel {
            return new WebhookDeliveryChannel(
                http: $app->make(HttpFactory::class),
                endpoint: (string) config('delivery.drivers.webhook.endpoint'),
                token: (string) config('delivery.drivers.webhook.token'),
            );
        });

        $this->app->extend(DeliveryChannel::class, function (DeliveryChannel $channel, Container $app): DeliveryChannel {
            return new LoggedDeliveryChannel(
                inner: $channel,
                logger: $app->make(LoggerInterface::class),
            );
        });
    }
}

Decorator celowo loguje referencję, nie treść ani odbiorcę. Przekrojowa funkcja to miejsce, w którym często zaczyna się przypadkowy wyciek danych osobowych. Używaj ustrukturyzowanego kontekstu, świadomie ustal retencję i spraw, aby awaria transportu była widoczna bez zapisywania poświadczeń lub całych payloadów.

Użyj managera, gdy konfiguracja wybiera rodzinę driverów

Decorator ma jedną wewnętrzną implementację. Manager rozwiązuje inny problem: aplikacja potrzebuje nazwanego drivera wybieranego w konfiguracji, a wszystkie drivery dzielą jeden kontrakt. Systemy cache, filesystemu, kolejek i powiadomień Laravela mają właśnie taki kształt. Gdy rozszerzasz konkretnie jeden z nich, użyj jego udokumentowanego extend().

Dla subsystemu należącego do aplikacji mały Manager czyni wybór jawnym, zamiast ukrywać go w rosnącym match.

php
<?php

declare(strict_types=1);

namespace App\Delivery;

use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Support\Manager;
use Psr\Log\LoggerInterface;

final class DeliveryManager extends Manager
{
    public function getDefaultDriver(): string
    {
        return (string) config('delivery.default');
    }

    protected function createWebhookDriver(): DeliveryChannel
    {
        return new WebhookDeliveryChannel(
            http: $this->container->make(HttpFactory::class),
            endpoint: (string) config('delivery.drivers.webhook.endpoint'),
            token: (string) config('delivery.drivers.webhook.token'),
        );
    }

    protected function createLogDriver(): DeliveryChannel
    {
        return new LogDeliveryChannel(
            logger: $this->container->make(LoggerInterface::class),
        );
    }
}

LogDeliveryChannel to kolejna pełna implementacja DeliveryChannel; jej metoda send() zapisuje do loggera wyłącznie referencję i typ odbiorcy. Konfiguracja mówi operatorowi, którego drivera użyć:

php
<?php

declare(strict_types=1);

return [
    'default' => env('DELIVERY_DRIVER', 'log'),

    'drivers' => [
        'webhook' => [
            'endpoint' => env('DELIVERY_WEBHOOK_ENDPOINT'),
            'token' => env('DELIVERY_WEBHOOK_TOKEN'),
        ],
    ],
];

Wywołania env() zostają w pliku konfiguracji. Każda klasa aplikacji odczytuje cache'owaną konfigurację przez config(). To rozróżnienie ma znaczenie po config:cache: wartości środowiskowe nie są API konfiguracji runtime.

Zarejestruj manager jako singleton. Manager cache'uje rozwiązane drivery; jeśli poświadczenia zmienią się w runtime, jawnie wywołaj forgetDrivers() albo, częściej, zrestartuj workery po wdrożeniu.

php
<?php

declare(strict_types=1);

namespace App\Providers;

use App\Delivery\DeliveryManager;
use Illuminate\Support\ServiceProvider;

final class DeliveryServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(DeliveryManager::class);
    }
}

Aplikacja lub paczka może zarejestrować dodatkowy driver bez edytowania managera:

php
<?php

declare(strict_types=1);

namespace App\Providers;

use App\Delivery\DeliveryChannel;
use App\Delivery\DeliveryManager;
use App\Delivery\LogDeliveryChannel;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;
use Psr\Log\LoggerInterface;

final class AuditDeliveryServiceProvider extends ServiceProvider
{
    public function boot(DeliveryManager $delivery): void
    {
        $delivery->extend('audit', function (Application $app): DeliveryChannel {
            return new LogDeliveryChannel(
                logger: $app->make(LoggerInterface::class),
            );
        });
    }
}

Nie twórz managera dla dwóch stałych implementacji wybieranych wyłącznie przez klasę klienta. W takiej sytuacji czytelniejsze będzie wiązanie kontekstowe albo mały resolver. Manager zasługuje na swój cykl życia dopiero wtedy, gdy czytelna dla człowieka konfiguracja i otwarty zbiór driverów są częścią problemu.

Dyrektywy to ostatni punkt rozszerzenia prezentacji

Komponenty Blade zwykle są lepsze od dyrektyw: mają jawne atrybuty, szablony i renderowanie możliwe do testowania. Dyrektywa jest przydatna dla małej składni, która bezpośrednio mapuje się na istniejące zachowanie Laravela. Ta formatuje kwotę w centach, nie ukrywając żadnego obliczenia biznesowego.

php
<?php

declare(strict_types=1);

namespace App\Providers;

use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;

final class BladeServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Blade::directive('money', static function (string $expression): string {
            return "<?php echo e(number_format(({$expression}) / 100, 2)); ?>";
        });
    }
}

Dyrektywa produkuje wyłącznie escapowany output. Nie pobiera kursów walut, nie odpytuje zamówień i nie decyduje, na jaką walutę zasługuje klient. Takie decyzje umieść w presenterze lub modelu widoku, a gotową wartość przekaż do komponentu. Dyrektywy kompilują się do PHP, więc niejasne dyrektywy są szczególnie bolesne w debugowaniu, gdy rozprzestrzenią się po szablonach.

Testuj punkt rozszerzenia, nie sam Laravel

Każde rozszerzenie ma małe zachowanie warte testu. Dla managera zbindowaj go w aplikacji testowej i sprawdź, czy wybrany driver spełnia kontrakt aplikacji. Dla decoratora sfake'uj klienta HTTP i wykonaj asercję na granicy.

php
<?php

declare(strict_types=1);

use App\Delivery\DeliveryChannel;
use App\Delivery\DeliveryManager;
use App\Delivery\LogDeliveryChannel;

it('resolves the configured delivery driver', function (): void {
    config()->set('delivery.default', 'log');

    $manager = app(DeliveryManager::class);

    expect($manager->driver())
        ->toBeInstanceOf(LogDeliveryChannel::class)
        ->toBeInstanceOf(DeliveryChannel::class);
});

Testy muszą także izolować stan macro. flushMacros() w afterEach() jest właściwe, gdy suite testowy rejestruje tymczasowe macra; w przeciwnym razie jeden test może sprawić, że inny pozornie przejdzie. W testach driverów po zmianie konfiguracji wyczyść cache'owane drivery managera. Implementacja jest poprawna tylko wtedy, gdy test kontroluje ten sam cykl życia co produkcja.

Kiedy NIE rozszerzać Laravela

Nie rozszerzaj Laravela tylko dlatego, że metoda jest niewygodna. Prywatny serwis aplikacji bywa bezpieczniejszy. Unikaj macro, gdy ma zależności albo kontekst użytkownika. Unikaj dyrektywy, gdy komponent Blade może opisać to samo UI. Unikaj extend() kontenera, gdy zachowania potrzebuje tylko jeden klient — wstrzyknij współpracownika bezpośrednio. Unikaj własnego managera, gdy wybór w runtime jest w rzeczywistości regułą biznesową wymagającą Strategy albo resolvera.

Przede wszystkim nie dziedzicz, nie monkey-patchuj i nie kopiuj wewnętrznych części frameworka, aby zmienić publiczną funkcję. Udokumentowane punkty rozszerzenia Laravela są granicami aktualizacji; chronione i prywatne wnętrze nimi nie jest. Jeśli żaden publiczny punkt nie pasuje, napisz serwis aplikacji obok Laravela, zamiast zmuszać framework, aby stał się Twoim modelem domenowym.

Pułapki produkcyjne

Pierwsza pułapka to cache konfiguracji: env() poza plikami konfiguracyjnymi może zwracać zaskakujące wartości po wdrożeniu. Druga to długość życia procesu: macra i singletonowe managery trwają w workerach kolejki i Octane, dlatego nie przechwytuj stanu konkretnego requestu i restartuj workery, gdy wdrożenie zmienia konfigurację lub kod.

Trzecia to kolejność. Paczka rozszerzająca istniejący manager Laravela musi zarejestrować driver przed rozwiązaniem go przez dowolnego providera; dokumentacja własnego drivera cache Laravela używa z tego powodu callbacka booting(). Na koniec oprzyj się puchnięciu abstrakcji. Nazwany punkt rozszerzenia powinien lokalizować kolejną zmianę. Jeśli dodanie drivera wymaga edycji kontrolerów, szablonów i tuzina warunków, brakująca granica leży w kodzie aplikacji, nie w Laravelu.

Zalecana ścieżka jest celowo konserwatywna: użyj publicznego hooka pasującego do rodzaju zmiany, zachowaj go cienkim, a właściwe zachowanie aplikacji oddaj zwykłym klasom wstrzykiwanym przez kontener.

Powiązane artykuły

Wsparcie istniejącego systemu

Potrzebujesz pomocy z działającą aplikacją?

Pomagam firmom rozwijać działające systemy, porządkować wdrożenia i dodawać nowe funkcje bez dokładania chaosu do projektu.

Komentarze (0)
Zaloguj się, aby dodać komentarz

Musisz być zalogowany, aby dodać komentarz.

Zaloguj się

Potrzebujesz kogoś, kto weźmie odpowiedzialność za kolejny krok?

Porozmawiajmy o Twoim projekcie i określmy zakres, który ma sens dla Twoich celów.