Service Container

Com funciona el Service Container de Laravel: inversió de control, binding, resolució automàtica, injecció de dependències i contextual binding.

Què és el Service Container?#

El Service Container és el cor de Laravel. És un contenidor d'inversió de control (IoC) que gestiona la creació, la configuració i la resolució de totes les dependències de l'aplicació. Quan diem "inversió de control" ens referim a un canvi fonamental en com les classes obtenen els seus col·laboradors: en lloc de crear-los elles mateixes (control directe), deleguen aquesta responsabilitat a un agent extern, el contenidor, que s'encarrega de proporcionar-los (control invertit).

Sense el contenidor, les teves classes crearien les seves dependències directament amb new. Això pot semblar senzill, però genera un acoblament fort: si canvies una implementació, has de modificar totes les classes que la instancien. Amb el contenidor, les classes simplement declaren "necessito un servei que compleixi aquesta interfície" i el contenidor s'encarrega de proporcionar la implementació correcta. Aquesta separació entre "què necessito" i "com es crea" és la clau de la testabilitat, la flexibilitat i el manteniment del codi.

Per entendre per què això importa, imagina una classe OrderService que necessita enviar correus i processar pagaments. Sense el contenidor, hauria de crear les instàncies ella mateixa:

// Sense contenidor: acoblament directe
class OrderService
{
    public function placeOrder(array $data): Order
    {
        $mailer = new SmtpMailer('smtp.gmail.com', 587, 'user', 'pass');
        $gateway = new StripePaymentGateway('sk_live_...');
 
        $order = Order::create($data);
        $gateway->charge($order->total);
        $mailer->send($order->user->email, 'Comanda confirmada');
 
        return $order;
    }
}

Aquesta classe és impossible de testejar sense enviar correus reals i processar pagaments reals. A més, si vols canviar de Stripe a PayPal, has de modificar OrderService i totes les altres classes que utilitzin StripePaymentGateway. Amb el contenidor i la injecció de dependències, el mateix codi es transforma en quelcom molt més flexible:

// Amb contenidor: desacoblat i testejable
class OrderService
{
    public function __construct(
        private PaymentGateway $gateway,
        private Mailer $mailer
    ) {}
 
    public function placeOrder(array $data): Order
    {
        $order = Order::create($data);
        $this->gateway->charge($order->total);
        $this->mailer->send($order->user->email, 'Comanda confirmada');
 
        return $order;
    }
}

Ara OrderService no sap ni li importa si el PaymentGateway és Stripe, PayPal o un mock de test. El contenidor s'encarrega de decidir-ho i de proporcionar la instància correcta.

Resolució automàtica (zero-configuration)#

Una de les funcionalitats més potents del Service Container és la seva capacitat de resoldre classes automàticament sense cap configuració. Si una classe no té dependències, o si totes les seves dependències són classes concretes (no interfícies), el contenidor pot crear-la directament sense que hagis de registrar res.

Això funciona gràcies a la reflexió de PHP. Quan li demanes al contenidor una classe que no ha estat registrada explícitament, el contenidor utilitza la Reflection API de PHP per inspeccionar el constructor de la classe, llegir els type-hints dels seus paràmetres, i resoldre recursivament cada dependència. Si una dependència també té dependències al seu constructor, el contenidor les resol de la mateixa manera, creant un arbre complet d'objectes sense que hagis escrit ni una línia de configuració.

class UserRepository
{
    // No necessita configuració: no té dependències
}
 
class NotificationService
{
    // No necessita configuració: DatabaseConnection es resol automàticament
    public function __construct(
        private DatabaseConnection $db
    ) {}
}
 
class UserController extends Controller
{
    // Totes les dependències es resolen automàticament per reflexió
    public function __construct(
        private UserRepository $repository,
        private NotificationService $notifications
    ) {}
}

En aquest exemple, quan Laravel necessita crear un UserController, el contenidor inspecciona el seu constructor i veu que necessita un UserRepository i un NotificationService. Llavors inspecciona el constructor de UserRepository (que no té dependències, així que el crea directament) i el constructor de NotificationService (que necessita un DatabaseConnection, que el contenidor també resol automàticament). Tot això passa sense que hagis registrat cap binding.

La resolució automàtica funciona amb classes concretes, no amb interfícies. Si el type-hint del constructor és una interfície (PaymentGateway) en lloc d'una classe concreta (StripePaymentGateway), el contenidor no pot saber quina implementació utilitzar i necessita un binding explícit. Aquesta és la raó principal per la qual existeixen els bindings: per indicar al contenidor com resoldre interfícies i classes abstractes.

Binding#

El binding és l'acte de dir-li al contenidor "quan algú demani aquesta classe o interfície, proporciona aquesta implementació". Hi ha diverses maneres de registrar bindings, cadascuna amb un comportament diferent.

bind() - Nova instància cada vegada#

El mètode bind() registra un binding que crea una nova instància cada cop que es resol. Això és útil per a serveis que mantenen estat intern i que no haurien de ser compartits entre diferents parts de l'aplicació:

use App\Services\PaymentGateway;
use App\Services\StripePaymentGateway;
 
// Binding simple: classe a classe
$this->app->bind(PaymentGateway::class, StripePaymentGateway::class);
 
// Binding amb closure per a configuració personalitzada
$this->app->bind(PaymentGateway::class, function ($app) {
    return new StripePaymentGateway(
        key: config('services.stripe.secret'),
        currency: config('services.stripe.currency', 'EUR'),
        logger: $app->make(Logger::class)
    );
});

Quan fas servir bind(), cada vegada que el contenidor resol PaymentGateway, executa la closure i crea una nova instància. Si 5 classes diferents demanen PaymentGateway, es crearan 5 objectes independents. Això pot ser exactament el que vols per a serveis que no haurien de compartir estat, però pot ser ineficient si la creació de l'objecte és costosa.

singleton() - Una sola instància compartida#

El mètode singleton() registra un binding que es resol una sola vegada. La primera resolució crea l'objecte i el guarda en cache. Totes les resolucions posteriors retornen el mateix objecte. Això és ideal per a serveis que són costosos de crear o que haurien de mantenir un estat consistent durant tota la petició:

// Singleton: una sola instància per a tota la petició
$this->app->singleton(Analytics::class, function ($app) {
    return new Analytics(
        apiKey: config('services.analytics.key'),
        cache: $app->make(CacheManager::class)
    );
});
 
// Singleton d'interfície a implementació
$this->app->singleton(
    CacheStore::class,
    RedisCacheStore::class
);

L'ús de singletons és habitual per a connexions a serveis externs (APIs, bases de dades), gestors de configuració, i serveis que agreguen dades durant la petició. Però cal tenir precaució: un singleton comparteix estat entre totes les parts de l'aplicació que l'utilitzen, cosa que pot causar efectes secundaris inesperats si no es gestiona correctament. En entorns com Laravel Octane, on l'aplicació persisteix entre peticions, un singleton pot retenir dades d'una petició anterior si no es neteja adequadament.

scoped() - Una instància per petició#

El mètode scoped() és similar a singleton(), però la instància es destrueix i es recrea per a cada nova petició HTTP o cada nou job de la cua. Això és especialment útil amb Laravel Octane o en aplicacions que processen múltiples peticions en el mateix procés:

// Scoped: una instància per petició, es destrueix entre peticions
$this->app->scoped(ShoppingCart::class, function ($app) {
    return new ShoppingCart(
        session: $app->make(SessionManager::class),
        currency: config('app.currency')
    );
});

La diferència entre singleton() i scoped() és subtil en aplicacions tradicionals (on cada petició és un procés PHP independent), però és crítica amb Octane. Amb Octane, un singleton persisteix entre peticions, mentre que un scoped es reinicia. Si tens un servei que acumula dades durant una petició (com un carret de compra o un registre d'activitat), scoped() garanteix que cada petició comença amb un estat net.

instance() - Vincular un objecte existent#

El mètode instance() vincula un objecte que ja existeix al contenidor. No es crea cap instància nova: el contenidor simplement retorna l'objecte que li has donat:

$stripe = new StripePaymentGateway(
    key: 'sk_test_...',
    webhook_secret: 'whsec_...'
);
 
// Vincular l'objecte ja creat
$this->app->instance(PaymentGateway::class, $stripe);

Això és útil quan necessites configurar un objecte amb lògica complexa abans de registrar-lo, o quan el test ha de substituir un servei per un mock ja preparat.

Binding d'interfícies a implementacions#

El cas d'ús més important dels bindings és vincular interfícies a implementacions concretes. Això és el que fa possible la inversió de dependències: les teves classes depenen d'interfícies (contractes), no d'implementacions, i el contenidor decideix quina implementació proporcionar:

// Definir la interfície (el contracte)
namespace App\Contracts;
 
interface PaymentGateway
{
    public function charge(int $amount, string $currency): PaymentResult;
    public function refund(string $transactionId): RefundResult;
}
 
// Implementació per a Stripe
namespace App\Services\Payment;
 
class StripePaymentGateway implements PaymentGateway
{
    public function __construct(
        private string $secretKey,
        private string $currency = 'EUR'
    ) {}
 
    public function charge(int $amount, string $currency): PaymentResult
    {
        // Lògica de Stripe
    }
 
    public function refund(string $transactionId): RefundResult
    {
        // Lògica de reemborsament Stripe
    }
}
 
// Implementació per a PayPal
namespace App\Services\Payment;
 
class PayPalPaymentGateway implements PaymentGateway
{
    public function __construct(
        private string $clientId,
        private string $clientSecret
    ) {}
 
    public function charge(int $amount, string $currency): PaymentResult
    {
        // Lògica de PayPal
    }
 
    public function refund(string $transactionId): RefundResult
    {
        // Lògica de reemborsament PayPal
    }
}

Al service provider, vincules la interfície a la implementació que vols utilitzar:

// Al service provider
$this->app->bind(PaymentGateway::class, function ($app) {
    return match (config('payment.driver')) {
        'stripe' => new StripePaymentGateway(
            secretKey: config('payment.stripe.secret'),
            currency: config('payment.currency')
        ),
        'paypal' => new PayPalPaymentGateway(
            clientId: config('payment.paypal.client_id'),
            clientSecret: config('payment.paypal.client_secret')
        ),
        default => throw new \RuntimeException('Driver de pagament no suportat'),
    };
});

Ara qualsevol classe que necessiti un PaymentGateway rebrà automàticament la implementació correcta segons la configuració. Canviar de Stripe a PayPal és tan senzill com modificar una variable d'entorn.

Resolving#

Un cop els serveis estan registrats al contenidor, necessites poder resoldre'ls. Hi ha diverses maneres de demanar al contenidor que et proporcioni una instància d'un servei.

El helper app()#

El helper app() és la manera més concisa de resoldre un servei:

// Resoldre una classe
$gateway = app(PaymentGateway::class);
 
// Resoldre amb paràmetres addicionals
$report = app(ReportGenerator::class, ['format' => 'pdf']);

El mètode make()#

El mètode make() és equivalent a app() però s'utilitza sobre la instància del contenidor. És la manera més formal i la que veuràs més sovint dins dels service providers:

// Obtenir la instància del contenidor i resoldre
$gateway = app()->make(PaymentGateway::class);
 
// Dins d'un service provider
$gateway = $this->app->make(PaymentGateway::class);
 
// Amb paràmetres
$service = $this->app->makeWith(NotificationService::class, [
    'channel' => 'slack',
]);

El helper resolve()#

El helper resolve() és un àlies de app()->make():

$gateway = resolve(PaymentGateway::class);

Resolució per type-hint (injecció automàtica)#

Aquesta és, de lluny, la manera més habitual i recomanada de resoldre dependències. En lloc de demanar explícitament al contenidor, simplement declares el type-hint al constructor o al mètode, i Laravel proporciona la instància automàticament:

class OrderController extends Controller
{
    // Injecció al constructor: la manera més habitual
    public function __construct(
        private PaymentGateway $gateway,
        private OrderRepository $orders
    ) {}
 
    // Injecció al mètode: disponible als controladors
    public function store(
        OrderRequest $request,
        DiscountCalculator $calculator
    ) {
        $discount = $calculator->calculate($request->coupon);
        $total = $request->total - $discount;
 
        $this->gateway->charge($total, 'EUR');
 
        return $this->orders->create($request->validated());
    }
}

La injecció automàtica funciona als constructors de qualsevol classe que Laravel resol (controladors, jobs, listeners, middleware, form requests, commands, mail, notifications) i als mètodes dels controladors. Laravel utilitza la reflexió de PHP per llegir els type-hints i resoldre automàticament cada dependència del contenidor.

Injecció automàtica de dependències#

L'injecció automàtica de dependències és la funcionalitat que converteix el Service Container d'una eina tècnica a una part invisible i elegant del flux de treball. Quan Laravel crea un controlador, un job, un listener o qualsevol altra classe gestionada pel framework, inspecciona el constructor amb la Reflection API, resol cada paràmetre segons el seu type-hint, i passa les instàncies resoltes al constructor. Tot això passa automàticament, sense que hagis d'escriure cap línia de resolució.

Injecció al constructor#

L'injecció al constructor és la forma principal. Les dependències es declaren com a paràmetres del constructor amb els seus type-hints, i Laravel les proporciona quan crea l'objecte:

class GenerateMonthlyReport implements ShouldQueue
{
    public function __construct(
        private ReportRepository $reports,
        private PdfGenerator $pdf,
        private Mailer $mailer
    ) {}
 
    public function handle(): void
    {
        $data = $this->reports->getMonthlyData();
        $file = $this->pdf->generate($data);
        $this->mailer->send(new MonthlyReportMail($file));
    }
}

Aquesta injecció funciona a controladors, jobs, listeners, middleware, form requests, comandes Artisan, notificacions, mailables i qualsevol classe que Laravel instanciï a través del contenidor.

Injecció al mètode del controlador#

Als controladors, Laravel també resol les dependències dels mètodes d'acció (les funcions que gestionen les rutes). Això és especialment útil per a dependències que només necessites en una acció concreta, en lloc de a tot el controlador:

class ProductController extends Controller
{
    public function __construct(
        private ProductRepository $products  // Disponible a totes les accions
    ) {}
 
    // ImageOptimizer només s'injecta en aquesta acció
    public function updateImage(
        Request $request,
        Product $product,
        ImageOptimizer $optimizer
    ) {
        $path = $request->file('image')->store('products');
        $optimizer->optimize(storage_path("app/{$path}"));
 
        $product->update(['image' => $path]);
 
        return redirect()->back();
    }
}

Laravel combina tres tipus de resolució al mètode: els paràmetres de la ruta ($product es resol per route model binding), el Request es resol com l'objecte de la petició actual, i ImageOptimizer es resol del contenidor. El framework sap distingir entre els tres tipus gràcies als type-hints.

Com funciona la reflexió#

Per entendre realment la injecció automàtica, és útil saber què fa Laravel internament. Quan el contenidor necessita resoldre una classe, segueix aquests passos:

  1. Comprova si hi ha un binding registrat per a la classe. Si n'hi ha, executa la closure o crea la classe vinculada.
  2. Si no hi ha binding, utilitza ReflectionClass per inspeccionar el constructor de la classe.
  3. Per a cada paràmetre del constructor, llegeix el type-hint amb ReflectionParameter::getType().
  4. Per a cada type-hint, intenta resoldre la dependència recursivament (tornant al pas 1).
  5. Si un paràmetre no té type-hint o té un tipus primitiu (string, int), comprova si hi ha un valor per defecte o un contextual binding.
  6. Un cop totes les dependències estan resoltes, crea la instància passant-les al constructor.
// Conceptualment, el contenidor fa quelcom similar a:
$reflection = new ReflectionClass(UserController::class);
$constructor = $reflection->getConstructor();
$parameters = $constructor->getParameters();
 
$dependencies = [];
foreach ($parameters as $parameter) {
    $type = $parameter->getType()->getName();
    $dependencies[] = $this->resolve($type); // Resolució recursiva
}
 
return $reflection->newInstanceArgs($dependencies);

Contextual binding#

De vegades, dues classes necessiten la mateixa interfície però amb implementacions diferents. Per exemple, un PhotoController potser necessita emmagatzemar fitxers a Amazon S3, mentre que un VideoController necessita emmagatzemar-los a un disc local per processar-los. Ambdós depenen de la interfície Filesystem, però cadascun necessita una implementació diferent.

El contextual binding resol exactament aquest problema: permet dir al contenidor "quan aquesta classe concreta demani aquesta interfície, proporciona aquesta implementació específica":

use App\Http\Controllers\PhotoController;
use App\Http\Controllers\VideoController;
use Illuminate\Contracts\Filesystem\Filesystem;
use App\Services\Storage\S3Filesystem;
use App\Services\Storage\LocalFilesystem;
 
// Al service provider
$this->app->when(PhotoController::class)
    ->needs(Filesystem::class)
    ->give(function () {
        return new S3Filesystem(config('filesystems.disks.s3'));
    });
 
$this->app->when(VideoController::class)
    ->needs(Filesystem::class)
    ->give(function () {
        return new LocalFilesystem(config('filesystems.disks.local'));
    });

Ara, quan Laravel crea el PhotoController, injecta un S3Filesystem, i quan crea el VideoController, injecta un LocalFilesystem. Ambdós controladors depenen de la interfície Filesystem (cosa que els fa testejables i desacoblats), però cada un rep la implementació adequada al seu cas d'ús.

Pots especificar múltiples classes consumidores al mateix temps si comparteixen la mateixa necessitat:

$this->app->when([PhotoController::class, GalleryController::class])
    ->needs(Filesystem::class)
    ->give(S3Filesystem::class);

Binding de primitives#

Quan una classe necessita valors primitius (strings, enters, booleans) al seu constructor, el contenidor no pot inferir-los per reflexió. El contextual binding de primitives permet injectar valors de configuració directament:

class ReportAggregator
{
    public function __construct(
        private ReportRepository $reports,
        private string $format,
        private int $maxPages
    ) {}
}
 
// Injectar valors de configuració
$this->app->when(ReportAggregator::class)
    ->needs('$format')
    ->giveConfig('reports.default_format');
 
$this->app->when(ReportAggregator::class)
    ->needs('$maxPages')
    ->give(50);

El mètode giveConfig() és una drecera convenient per a give(config('reports.default_format')). La diferència és que giveConfig() sempre llegeix el valor fresc de la configuració, mentre que give() amb una closure captura el valor al moment del binding.

Això és molt útil per a classes de serveis que necessiten paràmetres de configuració. En lloc d'accedir a config() dins de la classe (cosa que la faria dependent del framework), la classe declara els seus paràmetres al constructor i el contenidor s'encarrega de proporcionar-los.

Tagged bindings#

De vegades tens múltiples implementacions d'una interfície i necessites obtenir-les totes alhora. Per exemple, un sistema d'informes que genera informes de vendes, d'inventari i de clients, tots implementant la mateixa interfície Report. Els tagged bindings permeten agrupar bindings sota una etiqueta i resoldre'ls tots junts:

// Registrar les implementacions
$this->app->bind(SalesReport::class, function () {
    return new SalesReport(/* ... */);
});
 
$this->app->bind(InventoryReport::class, function () {
    return new InventoryReport(/* ... */);
});
 
$this->app->bind(CustomerReport::class, function () {
    return new CustomerReport(/* ... */);
});
 
// Etiquetar-les
$this->app->tag(
    [SalesReport::class, InventoryReport::class, CustomerReport::class],
    'reports'
);

Per resoldre tots els serveis etiquetats:

// Resoldre tots els informes etiquetats
$reports = $this->app->tagged('reports');
 
foreach ($reports as $report) {
    $report->generate();
}

Pots combinar tagged bindings amb la injecció al constructor utilitzant giveTagged():

class ReportAggregator
{
    public function __construct(
        private iterable $reports
    ) {}
 
    public function generateAll(): array
    {
        $results = [];
        foreach ($this->reports as $report) {
            $results[] = $report->generate();
        }
        return $results;
    }
}
 
// Injectar els serveis etiquetats
$this->app->when(ReportAggregator::class)
    ->needs('$reports')
    ->giveTagged('reports');

Extending bindings#

De vegades necessites modificar o decorar un servei després que ha estat resolt pel contenidor, sense tocar ni el binding original ni la classe del servei. El mètode extend() permet interceptar la resolució d'un servei i envolver-lo o modificar-lo:

// Afegir logging a un servei existent
$this->app->extend(PaymentGateway::class, function ($gateway, $app) {
    return new LoggingPaymentGateway(
        gateway: $gateway,
        logger: $app->make(Logger::class)
    );
});

En aquest exemple, cada vegada que es resol PaymentGateway, el contenidor primer crea la implementació original (per exemple, StripePaymentGateway) i després l'embolcalla amb LoggingPaymentGateway, que és un decorador que fa log de totes les operacions. El codi que utilitza PaymentGateway no nota cap diferència perquè el decorador implementa la mateixa interfície.

Aquest patró és especialment útil per a preocupacions transversals (cross-cutting concerns) com logging, cache o monitorització, i per a paquets que volen estendre serveis sense modificar-ne el binding original:

// Un paquet de monitorització podria estendre el mailer
$this->app->extend(Mailer::class, function ($mailer, $app) {
    return new MonitoredMailer(
        mailer: $mailer,
        monitor: $app->make(PerformanceMonitor::class)
    );
});

Container events#

El contenidor dispara events quan resol serveis, permetent-te executar lògica addicional en el moment de la resolució. El mètode resolving() registra un callback que es crida cada vegada que el contenidor resol un servei:

// Callback per a un servei específic
$this->app->resolving(PaymentGateway::class, function ($gateway, $app) {
    $gateway->setLogger($app->make(Logger::class));
});
 
// Callback global: es crida per a CADA resolució
$this->app->resolving(function ($object, $app) {
    if ($object instanceof Configurable) {
        $object->configure($app->make(ConfigRepository::class));
    }
});

El callback de resolving() rep dos paràmetres: l'objecte resolt i la instància del contenidor. Pots utilitzar-lo per configurar objectes després de la seva creació, injectar dependències opcionals o aplicar configuració comuna a múltiples serveis que implementen la mateixa interfície.

Els container events són una eina avançada que normalment no necessitaràs en aplicacions estàndard. Són més habituals en paquets que necessiten integrar-se amb serveis de l'aplicació de manera transparent, o en aplicacions amb requisits de configuració complexos.

La propietat $app als service providers#

Dins d'un service provider, la propietat $this->app és una referència directa a la instància del Service Container (que és alhora la instància de l'aplicació Laravel). Aquesta propietat és la que utilitzes per registrar bindings:

class PaymentServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // $this->app és el contenidor
        $this->app->singleton(PaymentGateway::class, function ($app) {
            // $app dins la closure també és el contenidor
            return new StripePaymentGateway(
                key: config('services.stripe.secret'),
                logger: $app->make(Logger::class)
            );
        });
    }
}

Fixa't que tant $this->app com el paràmetre $app de la closure fan referència al Service Container. La diferència és el context: $this->app està disponible a qualsevol mètode del provider, mentre que $app dins la closure es passa automàticament quan el contenidor executa la closure per resoldre el binding.

Exemple pràctic: sistema de pagaments#

Posem en pràctica tot el que hem après construint un sistema de pagaments complet amb interfícies, implementacions, bindings i injecció de dependències.

Primer, definim la interfície del contracte de pagament:

// app/Contracts/PaymentGateway.php
namespace App\Contracts;
 
interface PaymentGateway
{
    public function charge(int $amountInCents, string $currency = 'EUR'): PaymentResult;
    public function refund(string $transactionId, ?int $amount = null): RefundResult;
    public function getTransaction(string $transactionId): Transaction;
}
// app/DTOs/PaymentResult.php
namespace App\DTOs;
 
class PaymentResult
{
    public function __construct(
        public readonly bool $success,
        public readonly string $transactionId,
        public readonly int $amount,
        public readonly string $currency,
        public readonly ?string $errorMessage = null
    ) {}
}

Ara creem les implementacions:

// app/Services/Payment/StripePaymentGateway.php
namespace App\Services\Payment;
 
use App\Contracts\PaymentGateway;
use App\DTOs\PaymentResult;
use App\DTOs\RefundResult;
use App\DTOs\Transaction;
use Stripe\StripeClient;
 
class StripePaymentGateway implements PaymentGateway
{
    private StripeClient $client;
 
    public function __construct(
        private string $secretKey,
        private string $webhookSecret
    ) {
        $this->client = new StripeClient($this->secretKey);
    }
 
    public function charge(int $amountInCents, string $currency = 'EUR'): PaymentResult
    {
        try {
            $intent = $this->client->paymentIntents->create([
                'amount' => $amountInCents,
                'currency' => strtolower($currency),
                'automatic_payment_methods' => ['enabled' => true],
            ]);
 
            return new PaymentResult(
                success: true,
                transactionId: $intent->id,
                amount: $amountInCents,
                currency: $currency
            );
        } catch (\Exception $e) {
            return new PaymentResult(
                success: false,
                transactionId: '',
                amount: $amountInCents,
                currency: $currency,
                errorMessage: $e->getMessage()
            );
        }
    }
 
    public function refund(string $transactionId, ?int $amount = null): RefundResult
    {
        $params = ['payment_intent' => $transactionId];
        if ($amount !== null) {
            $params['amount'] = $amount;
        }
 
        $refund = $this->client->refunds->create($params);
 
        return new RefundResult(
            success: $refund->status === 'succeeded',
            refundId: $refund->id
        );
    }
 
    public function getTransaction(string $transactionId): Transaction
    {
        $intent = $this->client->paymentIntents->retrieve($transactionId);
 
        return new Transaction(
            id: $intent->id,
            amount: $intent->amount,
            currency: $intent->currency,
            status: $intent->status
        );
    }
}

Registrem tot al service provider:

// app/Providers/PaymentServiceProvider.php
namespace App\Providers;
 
use App\Contracts\PaymentGateway;
use App\Services\Payment\StripePaymentGateway;
use Illuminate\Support\ServiceProvider;
 
class PaymentServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(PaymentGateway::class, function ($app) {
            return new StripePaymentGateway(
                secretKey: config('services.stripe.secret'),
                webhookSecret: config('services.stripe.webhook_secret')
            );
        });
    }
}

I ara el controlador pot utilitzar el servei sense saber res de la implementació:

// app/Http/Controllers/CheckoutController.php
namespace App\Http\Controllers;
 
use App\Contracts\PaymentGateway;
use App\Http\Requests\CheckoutRequest;
use App\Models\Order;
 
class CheckoutController extends Controller
{
    public function __construct(
        private PaymentGateway $gateway
    ) {}
 
    public function process(CheckoutRequest $request)
    {
        $order = Order::create($request->validated());
 
        $result = $this->gateway->charge(
            amountInCents: $order->total_cents,
            currency: 'EUR'
        );
 
        if ($result->success) {
            $order->update([
                'payment_status' => 'paid',
                'transaction_id' => $result->transactionId,
            ]);
 
            return redirect()->route('orders.show', $order)
                ->with('success', 'Pagament processat correctament.');
        }
 
        $order->update(['payment_status' => 'failed']);
 
        return back()->withErrors([
            'payment' => "Error al processar el pagament: {$result->errorMessage}",
        ]);
    }
}

Avantatge per als tests#

Un dels beneficis principals del Service Container és la facilitat per substituir implementacions durant els tests. En lloc de fer peticions reals a Stripe, pots crear un mock o una implementació de test:

// app/Services/Payment/FakePaymentGateway.php
namespace App\Services\Payment;
 
use App\Contracts\PaymentGateway;
use App\DTOs\PaymentResult;
use App\DTOs\RefundResult;
use App\DTOs\Transaction;
 
class FakePaymentGateway implements PaymentGateway
{
    private array $charges = [];
 
    public function charge(int $amountInCents, string $currency = 'EUR'): PaymentResult
    {
        $transactionId = 'fake_' . uniqid();
        $this->charges[] = compact('amountInCents', 'currency', 'transactionId');
 
        return new PaymentResult(
            success: true,
            transactionId: $transactionId,
            amount: $amountInCents,
            currency: $currency
        );
    }
 
    public function refund(string $transactionId, ?int $amount = null): RefundResult
    {
        return new RefundResult(success: true, refundId: 'refund_' . uniqid());
    }
 
    public function getTransaction(string $transactionId): Transaction
    {
        return new Transaction(
            id: $transactionId,
            amount: 0,
            currency: 'EUR',
            status: 'succeeded'
        );
    }
 
    // Mètodes auxiliars per als tests
    public function getCharges(): array
    {
        return $this->charges;
    }
 
    public function totalChargedInCents(): int
    {
        return array_sum(array_column($this->charges, 'amountInCents'));
    }
}

Al test, substituïm la implementació real per la falsa:

namespace Tests\Feature;
 
use App\Contracts\PaymentGateway;
use App\Services\Payment\FakePaymentGateway;
use App\Models\User;
use Tests\TestCase;
 
class CheckoutTest extends TestCase
{
    public function test_user_can_complete_checkout(): void
    {
        $fakeGateway = new FakePaymentGateway;
 
        // Substituir la implementació real per la falsa
        $this->app->instance(PaymentGateway::class, $fakeGateway);
 
        $user = User::factory()->create();
 
        $response = $this->actingAs($user)->post('/checkout', [
            'items' => [['product_id' => 1, 'quantity' => 2]],
            'total_cents' => 5000,
        ]);
 
        $response->assertRedirect();
 
        // Verificar que el pagament s'ha processat correctament
        $this->assertCount(1, $fakeGateway->getCharges());
        $this->assertEquals(5000, $fakeGateway->totalChargedInCents());
    }
}

El test és ràpid (no fa crides HTTP a Stripe), fiable (no depèn d'un servei extern) i determinista (sempre produeix el mateix resultat). Això és el poder de la inversió de dependències i el Service Container: la mateixa interfície PaymentGateway funciona amb Stripe en producció i amb un fake als tests, i el codi de l'aplicació no canvia ni una línia.

El Service Container és l'eina que converteix codi rígid i difícil de testejar en codi flexible i modular. Invertir dependències, programar contra interfícies i deixar que el contenidor gestioni la creació d'objectes són hàbits que milloraran la qualitat de qualsevol projecte Laravel, independentment de la seva mida.