HardТеория8 min

Service Container (IoC)

IoC-контейнер, привязки (bind, singleton, instance), контекстные привязки, автоматическое разрешение, теги

Service Container (IoC-контейнер)

Что такое Service Container

Service Container (Inversion of Control Container) -- это мощный инструмент для управления зависимостями классов и выполнения внедрения зависимостей (Dependency Injection). Это сердце Laravel-приложения.

<?php
declare(strict_types=1);

// The Application class IS the Service Container
// Illuminate\Foundation\Application extends Illuminate\Container\Container

// Access the container:
$container = app();                      // Global helper
$container = \Illuminate\Container\Container::getInstance();
$container = $this->app;                 // In service providers
$container = resolve('SomeClass');       // Resolve from container

Ключевой концепт: Inversion of Control (IoC) означает, что вместо того чтобы класс сам создавал свои зависимости, они внедряются извне (контейнером). Это обеспечивает слабую связанность, тестируемость и гибкость.

Автоматическое разрешение (Zero-Configuration Resolution)

Одна из самых мощных возможностей контейнера -- автоматическое разрешение зависимостей без явной регистрации.

<?php
declare(strict_types=1);

namespace App\Services;

// No binding needed — container resolves this automatically
final class OrderService
{
    public function __construct(
        private readonly PaymentGateway $payment,
        private readonly InventoryService $inventory,
        private readonly NotificationService $notifications,
    ) {}

    public function processOrder(Order $order): void
    {
        $this->inventory->reserve($order);
        $this->payment->charge($order);
        $this->notifications->sendConfirmation($order);
    }
}

// Container uses PHP Reflection API to:
// 1. Inspect constructor parameters
// 2. Determine type hints
// 3. Recursively resolve each dependency
// 4. Create instance with all dependencies injected

// This works WITHOUT any explicit binding:
$service = app(OrderService::class);
// Container automatically creates PaymentGateway, InventoryService,
// NotificationService, and injects them into OrderService
<?php
declare(strict_types=1);

// Automatic resolution ONLY works for concrete classes
// It CANNOT resolve:
// - Interfaces (no way to know which implementation)
// - Abstract classes
// - Scalar types (string, int, bool)
// - Union/intersection types (ambiguous)

// Example that FAILS without binding:
namespace App\Services;

use App\Contracts\PaymentGatewayInterface;

final class CheckoutService
{
    public function __construct(
        private readonly PaymentGatewayInterface $gateway, // Interface — needs binding
        private readonly string $currency,                  // Scalar — needs binding
    ) {}
}

Для экзамена: Контейнер использует PHP Reflection API для анализа конструктора. Автоматическое разрешение работает ТОЛЬКО для конкретных классов с типизированными параметрами конструктора. Интерфейсы, абстрактные классы и скалярные типы требуют явной привязки.

Привязки (Bindings)

bind() -- новый экземпляр каждый раз

<?php
declare(strict_types=1);

namespace App\Providers;

use App\Contracts\PaymentGatewayInterface;
use App\Services\StripePaymentGateway;
use Illuminate\Support\ServiceProvider;

final class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Simple binding: interface → implementation
        $this->app->bind(
            PaymentGatewayInterface::class,
            StripePaymentGateway::class,
        );

        // Binding with closure (for complex construction)
        $this->app->bind(PaymentGatewayInterface::class, function ($app) {
            return new StripePaymentGateway(
                apiKey: config('services.stripe.secret'),
                currency: config('services.stripe.currency', 'usd'),
                logger: $app->make(\Psr\Log\LoggerInterface::class),
            );
        });

        // IMPORTANT: bind() creates a NEW instance EACH TIME it's resolved
        $a = app(PaymentGatewayInterface::class);
        $b = app(PaymentGatewayInterface::class);
        // $a !== $b → different instances
    }
}

singleton() -- один экземпляр на весь жизненный цикл

<?php
declare(strict_types=1);

namespace App\Providers;

use App\Services\CartService;
use App\Services\CacheManager;
use Illuminate\Support\ServiceProvider;

final class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Singleton: one instance for the entire request lifecycle
        $this->app->singleton(CartService::class, function ($app) {
            return new CartService(
                session: $app->make('session.store'),
            );
        });

        // Simple singleton (class resolves itself)
        $this->app->singleton(CacheManager::class);

        // Interface to implementation singleton
        $this->app->singleton(
            \App\Contracts\CacheInterface::class,
            \App\Services\RedisCacheService::class,
        );

        // Same instance returned every time
        $a = app(CartService::class);
        $b = app(CartService::class);
        // $a === $b → same instance
    }
}

Ловушка экзамена: bind() создаёт НОВЫЙ экземпляр при каждом разрешении. singleton() создаёт экземпляр при ПЕРВОМ разрешении и затем возвращает его при всех последующих. Выбор между ними зависит от того, должен ли объект хранить состояние, разделяемое между вызовами.

scoped() -- один экземпляр на запрос

<?php
declare(strict_types=1);

namespace App\Providers;

use App\Services\RequestContext;
use Illuminate\Support\ServiceProvider;

final class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Scoped: one instance per request
        // Similar to singleton, but flushed between requests
        // in long-running contexts (Octane, queue workers)
        $this->app->scoped(RequestContext::class, function ($app) {
            return new RequestContext(
                ip: request()->ip(),
                userAgent: request()->userAgent(),
            );
        });

        // IMPORTANT: In traditional PHP (php-fpm), scoped() and singleton()
        // behave identically because each request is a fresh process.
        // The difference matters in:
        // - Laravel Octane (persistent process)
        // - Queue workers (long-running process)
        // - Laravel Reverb (WebSocket server)
    }
}

Для Senior: Метод scoped() особенно важен при использовании Laravel Octane, где процесс PHP не завершается между запросами. Singleton-зависимости в Octane будут сохранять состояние между запросами, что может привести к утечке данных. Scoped-привязки автоматически сбрасываются.

instance() -- привязка существующего объекта

<?php
declare(strict_types=1);

namespace App\Providers;

use App\Services\AppConfig;
use Illuminate\Support\ServiceProvider;

final class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Bind an already created instance
        $config = new AppConfig(
            version: '2.0.0',
            features: ['dark-mode', 'notifications'],
        );

        $this->app->instance(AppConfig::class, $config);

        // The exact same object is returned every time
        $resolved = app(AppConfig::class);
        // $resolved === $config → true

        // Useful for:
        // - Testing (inject mocks)
        // - Pre-configured objects
        // - External service instances
    }
}

Контекстные привязки

Контекстные привязки позволяют разрешать разные реализации одного интерфейса в зависимости от того, куда они внедряются.

<?php
declare(strict_types=1);

namespace App\Providers;

use App\Contracts\FileStorageInterface;
use App\Services\LocalStorage;
use App\Services\S3Storage;
use App\Services\PhotoUploader;
use App\Services\DocumentUploader;
use Illuminate\Support\ServiceProvider;

final class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // When PhotoUploader needs FileStorageInterface → give S3Storage
        $this->app->when(PhotoUploader::class)
            ->needs(FileStorageInterface::class)
            ->give(S3Storage::class);

        // When DocumentUploader needs FileStorageInterface → give LocalStorage
        $this->app->when(DocumentUploader::class)
            ->needs(FileStorageInterface::class)
            ->give(LocalStorage::class);

        // Contextual binding with closure
        $this->app->when(ReportGenerator::class)
            ->needs(FileStorageInterface::class)
            ->give(function ($app) {
                return new S3Storage(
                    bucket: config('filesystems.disks.reports.bucket'),
                );
            });

        // Binding scalar values contextually
        $this->app->when(StripePaymentGateway::class)
            ->needs('$apiKey')
            ->give(config('services.stripe.secret'));

        // Using giveConfig shorthand
        $this->app->when(StripePaymentGateway::class)
            ->needs('$apiKey')
            ->giveConfig('services.stripe.secret');
    }
}
<?php
declare(strict_types=1);

namespace App\Services;

use App\Contracts\FileStorageInterface;

final class PhotoUploader
{
    public function __construct(
        private readonly FileStorageInterface $storage, // Will receive S3Storage
    ) {}
}

final class DocumentUploader
{
    public function __construct(
        private readonly FileStorageInterface $storage, // Will receive LocalStorage
    ) {}
}

Для экзамена: Контекстные привязки -- один из самых мощных механизмов контейнера. Метод when()->needs()->give() позволяет разрешать разные реализации одного интерфейса в зависимости от потребителя. Метод giveConfig() -- удобный шорткат для привязки конфигурационных значений.

Тегирование (Tagging)

<?php
declare(strict_types=1);

namespace App\Providers;

use App\Reports\CsvReportExporter;
use App\Reports\PdfReportExporter;
use App\Reports\XlsxReportExporter;
use App\Contracts\ReportExporterInterface;
use Illuminate\Support\ServiceProvider;

final class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Register multiple implementations
        $this->app->bind('report.csv', CsvReportExporter::class);
        $this->app->bind('report.pdf', PdfReportExporter::class);
        $this->app->bind('report.xlsx', XlsxReportExporter::class);

        // Tag them for batch resolution
        $this->app->tag(
            ['report.csv', 'report.pdf', 'report.xlsx'],
            'report.exporters'
        );
    }
}
<?php
declare(strict_types=1);

namespace App\Services;

use App\Contracts\ReportExporterInterface;

final class ReportService
{
    /** @var ReportExporterInterface[] */
    private array $exporters;

    public function __construct()
    {
        // Resolve all tagged services
        $this->exporters = iterator_to_array(app()->tagged('report.exporters'));
    }

    public function export(Report $report, string $format): string
    {
        foreach ($this->exporters as $exporter) {
            if ($exporter->supports($format)) {
                return $exporter->export($report);
            }
        }

        throw new \InvalidArgumentException("Unsupported format: {$format}");
    }
}

Расширение привязок (Extending)

<?php
declare(strict_types=1);

namespace App\Providers;

use App\Services\PaymentGateway;
use App\Services\LoggingPaymentGateway;
use Illuminate\Support\ServiceProvider;

final class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(PaymentGateway::class);

        // Extend the resolved instance (Decorator pattern)
        $this->app->extend(PaymentGateway::class, function ($gateway, $app) {
            // Wrap the original service with a decorator
            return new LoggingPaymentGateway(
                gateway: $gateway,
                logger: $app->make(\Psr\Log\LoggerInterface::class),
            );
        });

        // extend() is called after the service is resolved
        // but before it's returned to the consumer
        // Perfect for the Decorator pattern
    }
}

Разрешение зависимостей

Способы получения сервисов из контейнера

<?php
declare(strict_types=1);

use App\Services\OrderService;

// Method 1: app() helper
$service = app(OrderService::class);

// Method 2: resolve() helper
$service = resolve(OrderService::class);

// Method 3: app()->make()
$service = app()->make(OrderService::class);

// Method 4: app()->makeWith() — with runtime parameters
$service = app()->makeWith(OrderService::class, [
    'currency' => 'EUR',
]);

// Method 5: Constructor injection (PREFERRED)
final class CheckoutController
{
    public function __construct(
        private readonly OrderService $orderService,
    ) {}
}

// Method 6: Method injection (in controllers)
final class CheckoutController
{
    public function store(Request $request, OrderService $orderService): Response
    {
        // $orderService is injected by the container
    }
}

// Method 7: Facade (static proxy to container instance)
// OrderService::process($order);
// → Actually calls: app(OrderService::class)->process($order)

Проверка привязок

<?php
declare(strict_types=1);

// Check if a binding exists
$exists = app()->bound(OrderService::class); // true/false

// Check if instance is already resolved (singleton)
$resolved = app()->resolved(OrderService::class); // true/false

// Get the alias for an abstract
$alias = app()->getAlias(OrderService::class);

// Check if abstract is a shared (singleton) binding
$isShared = app()->isShared(OrderService::class);

Привязки интерфейсов (Interface Binding)

<?php
declare(strict_types=1);

// The MOST IMPORTANT use of the container:
// Binding interfaces to implementations

// Contract (Interface):
namespace App\Contracts;

interface NotificationChannelInterface
{
    public function send(User $user, string $message): void;
}

// Implementation 1:
namespace App\Services\Notifications;

final class EmailNotificationChannel implements NotificationChannelInterface
{
    public function send(User $user, string $message): void
    {
        // Send email
    }
}

// Implementation 2:
final class SmsNotificationChannel implements NotificationChannelInterface
{
    public function send(User $user, string $message): void
    {
        // Send SMS
    }
}

// Binding:
$this->app->bind(
    NotificationChannelInterface::class,
    EmailNotificationChannel::class,
);

// Now any class type-hinting NotificationChannelInterface
// will receive EmailNotificationChannel

// To switch to SMS — change ONE line in ServiceProvider
// No changes needed in consuming classes (Open/Closed Principle)

Контейнерные события

<?php
declare(strict_types=1);

namespace App\Providers;

use App\Services\OrderService;
use Illuminate\Support\ServiceProvider;

final class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Execute callback when a specific class is resolved
        $this->app->resolving(OrderService::class, function ($service, $app) {
            // Called every time OrderService is resolved
            // Useful for setting up the service
            $service->setDefaultCurrency(config('app.currency'));
        });

        // Execute callback when ANY class is resolved
        $this->app->resolving(function ($object, $app) {
            // Called for every resolution
            // Be careful — this fires A LOT
        });

        // After resolving callback
        $this->app->afterResolving(OrderService::class, function ($service) {
            // Called after the service is fully resolved
            // and after resolving callbacks
        });
    }
}

Паттерн использования в тестах

<?php
declare(strict_types=1);

namespace Tests\Feature;

use App\Contracts\PaymentGatewayInterface;
use App\Services\FakePaymentGateway;
use Tests\TestCase;

final class CheckoutTest extends TestCase
{
    public function test_successful_checkout(): void
    {
        // Replace binding for testing
        $this->app->instance(
            PaymentGatewayInterface::class,
            new FakePaymentGateway(shouldSucceed: true),
        );

        // Or use mock
        $mock = $this->mock(PaymentGatewayInterface::class);
        $mock->shouldReceive('charge')
            ->once()
            ->andReturn(true);

        $response = $this->post('/checkout', [
            'product_id' => 1,
            'quantity' => 2,
        ]);

        $response->assertStatus(200);
    }

    public function test_swap_binding(): void
    {
        // swap() replaces the binding for the current test
        $this->swap(PaymentGatewayInterface::class, new FakePaymentGateway());

        // partialMock() creates a partial mock
        $this->partialMock(PaymentGatewayInterface::class, function ($mock) {
            $mock->shouldReceive('charge')->andReturn(true);
        });
    }
}

Проверь себя

Как реализовать контекстную привязку, чтобы класс `PhotoService` получал `S3Storage`, а класс `LogService` — `LocalStorage`?

Что произойдёт при попытке автоматического разрешения класса с параметром конструктора типа `string`?

Какой метод контейнера используется для реализации паттерна Decorator при разрешении сервиса?

Для чего нужен метод `scoped()` и в чём его отличие от `singleton()`?

Чем отличается `bind()` от `singleton()` в Service Container?