Laravel Tutorial · Lessons

Foundation

Basic

Medium

Advanced

Service Container & Facades

Dependency injection, service providers and how facades really work.

The service container is the most important class in Laravel that most developers never call directly. Every controller, middleware, job, listener, command and Blade component is built by it. Facades, helpers like app() and config(), and dependency injection in method signatures are all front-ends to the same object.

This lesson explains what the container does, how service providers feed it, and how facades turn Cache::get() into a call on a real object. Examples captioned with a file path come from this app: its CMS services and the Projects & Tasks demo API. Blocks marked Illustrative use hypothetical services to show a technique the app doesn't need.

The problem it solves

Illustrative. Imagine a TaskReminderService that needs a mailer, a clock and a logger:

class TaskReminderService
{
    public function __construct(
        private Mailer $mailer,
        private Clock $clock,
        private LoggerInterface $logger,
    ) {}
}

Without a container, every caller has to know how to build the whole graph:

$service = new TaskReminderService(
    new SmtpMailer(new SmtpTransport(config('mail.host'), config('mail.port'))),
    new SystemClock(),
    new FileLogger(storage_path('logs/laravel.log')),
);

That's brittle (change one constructor and you edit every caller) and untestable (you can't swap in a fake mailer). Dependency injection says a class should receive its dependencies, not build them. The container is the thing that does the building.

Auto-wiring with reflection

For most classes you don't configure anything. When you type-hint a concrete class, the container uses PHP's Reflection API to read the constructor. It resolves each parameter recursively, then calls new:

app()->make( TaskController) Reflect constructor TaskReminderService $s Mailer (interface) binding found → SmtpMailer SystemClock (class) no binding → auto-wire, new LoggerInterface singleton → reuse instance recursive: each dependency is resolved the same way

A toy version of the algorithm fits on one screen:

class TinyContainer
{
    private array $bindings = [];
    private array $instances = [];

    public function bind(string $abstract, Closure|string $concrete, bool $shared = false): void
    {
        $this->bindings[$abstract] = compact('concrete', 'shared');
    }

    public function make(string $abstract): object
    {
        if (isset($this->instances[$abstract])) {
            return $this->instances[$abstract];             // singleton cache
        }

        $binding  = $this->bindings[$abstract] ?? ['concrete' => $abstract, 'shared' => false];
        $concrete = $binding['concrete'];

        $object = $concrete instanceof Closure
            ? $concrete($this)                              // factory closure
            : $this->build($concrete);                      // auto-wire

        if ($binding['shared']) {
            $this->instances[$abstract] = $object;
        }

        return $object;
    }

    private function build(string $class): object
    {
        $reflector   = new ReflectionClass($class);
        $constructor = $reflector->getConstructor();

        if (! $constructor) {
            return new $class;
        }

        $args = array_map(
            fn (ReflectionParameter $p) => $this->make($p->getType()->getName()),
            $constructor->getParameters(),
        );

        return $reflector->newInstanceArgs($args);
    }
}

Laravel's real Illuminate\Container\Container adds contextual bindings, primitives, variadics, extend(), resolving callbacks and circular-dependency detection, but the core really is this.

Binding things into the container

You only need to register a binding when the container can't work it out: an interface, a class that needs scalar config, or something that should be shared.

Method Behaviour Typical use
bind(A::class, B::class) New instance every time Stateless services, interface → implementation
singleton(A::class, fn () => ...) Built once, then reused for the app's lifetime API clients, config-heavy services
scoped(A::class, ...) Singleton, reset per request/job (Octane, queues) Per-request state
instance(A::class, $obj) Register an already-built object Tests, pre-configured objects
when(X::class)->needs(A::class)->give(B::class) Contextual binding Two consumers need different implementations

Illustrative, not in this app:

public function register(): void
{
    // Interface → implementation
    $this->app->bind(TaskNotifier::class, SlackTaskNotifier::class);

    // Needs scalar config, so use a factory closure
    $this->app->singleton(WeatherClient::class, fn ($app) => new WeatherClient(
        apiKey: config('services.weather.key'),
        timeout: 5,
    ));

    // Contextual: reports go to S3, avatars to the public disk
    $this->app->when(ReportExporter::class)
        ->needs(Filesystem::class)
        ->give(fn () => Storage::disk('s3'));
}

This app has exactly one explicit binding. SiteConfigService fetches the site's title and colours from the Django CMS manager over HTTP, so it's registered as a singleton (with a 5-minute cache behind it):

app/Providers/AppServiceProvider.php

public function register(): void
{
    $this->app->singleton(SiteConfigService::class);
}

Everything else in the app, including every Projects & Tasks controller, request, resource and listener, is a concrete class, so it's auto-wired with no registration at all.

Try the resolution rules

Pick a binding type and resolve the same service twice. Watch whether you get the same instance. Then click Next request.

Why does "Next request" matter? Under PHP-FPM, which is how this site runs, every HTTP request boots a fresh application, so a singleton only lives for one request anyway. The difference between singleton() and scoped() shows up in long-lived processes: a queue:work worker or Laravel Octane boots once and handles many jobs or requests. There, a singleton survives from one job to the next, and so does any state it holds, while a scoped instance is flushed between them.

Where injection happens

The container doesn't just build constructors. Laravel calls a lot of methods through it too, and this app uses every flavour.

Constructor injection. The controller that renders this page receives a repository:

app/Http/Controllers/TutorialController.php

public function __construct(private TutorialRepository $tutorials) {}

Method injection with a Form Request. Type-hinting StoreTaskRequest doesn't just inject an object. The container resolves it, and the request validates itself before your method body runs. If validation fails, the method is never called and the client gets a 422:

app/Http/Controllers/Api/TaskController.php

/**
 * Run the real StoreTaskRequest rules without saving anything.
 */
public function validateOnly(StoreTaskRequest $request): JsonResponse
{
    return response()->json([
        'valid' => true,
        'data' => $request->validated(),
    ]);
}

Method injection with route model binding. {project} in the URL becomes a loaded Project:

app/Http/Controllers/Api/ProjectController.php

public function show(Project $project): ProjectResource

Listener injection. When Task::markComplete() dispatches TaskCompleted, the queue worker builds the listener through the container and passes it the event:

app/Listeners/LogTaskCompletion.php

class LogTaskCompletion implements ShouldQueue
{
    use InteractsWithQueue;

    public int $tries = 3;

    /**
     * @var array<int, int>
     */
    public array $backoff = [10, 60];

    public function handle(TaskCompleted $event): void
    {
        $task = $event->task->loadMissing('project');

        Log::info('Task completed', [
            'task_id' => $task->id,
            'title' => $task->title,
            'project' => $task->project->slug,
        ]);
    }
}

The same applies to job handle() methods, Artisan command handle() methods, middleware constructors and Blade component constructors. You can also call any closure or method this way yourself:

app()->call([new ReportBuilder, 'build'], ['month' => 'October']);   // illustrative

Service providers

A service provider is where an app or package tells the container about itself. Providers are listed in bootstrap/providers.php. Packages are auto-discovered via their composer.json. This app has two:

bootstrap/providers.php

return [
    AppServiceProvider::class,
    AdminPanelProvider::class,
];

Illustrative. A dedicated provider for a feature would look like this:

class TaskServiceProvider extends ServiceProvider
{
    // Phase 1: ONLY bind things. Other services may not exist yet.
    public function register(): void
    {
        $this->app->singleton(TaskNotifier::class, SlackTaskNotifier::class);
    }

    // Phase 2: everything is registered; safe to use any service.
    public function boot(): void
    {
        Gate::policy(Task::class, TaskPolicy::class);
        Event::listen(TaskCompleted::class, NotifyProjectOwner::class);
        View::composer('projects.*', ProjectSidebarComposer::class);
    }
}

This app doesn't need Gate::policy() or Event::listen(). Since Laravel 11, TaskPolicy is auto-discovered for the Task model by naming convention, and LogTaskCompletion is auto-discovered as a listener because its handle() method type-hints TaskCompleted. A test proves the wiring exists:

tests/Feature/Demo/TaskCompletionTest.php

public function test_listener_is_registered_for_the_event(): void
{
    Event::fake();

    Event::assertListening(TaskCompleted::class, LogTaskCompletion::class);
}

What the app does do in boot() is define the demo API's rate limiter and share the site config with every view. That's why the layout can read $siteConfig['primary_color'] without each controller passing it:

app/Providers/AppServiceProvider.php

public function boot(): void
{
    // Public tutorial demo API: 120 requests a minute per IP
    RateLimiter::for('demo', fn (Request $request) => Limit::perMinute(120)->by($request->ip()));

    // Share site config with every view
    View::composer('*', function (\Illuminate\View\View $view) {
        $config = app(SiteConfigService::class)->get();
        $view->with('siteConfig', $config);
    });
}

Both belong in boot(). The RateLimiter facade and the view factory are services that must already be registered, and throttle:demo in routes/api.php refers to the limiter by name.

Why are there two methods? Because providers run in sequence. If provider A's register() resolved a service that provider B hasn't bound yet, it would break, or silently get the wrong implementation. Splitting register (declare) from boot (use) guarantees every binding exists before anything is resolved. Deferred providers (implements DeferrableProvider) go further and aren't loaded at all until one of their services is requested.

Facades: static syntax, real objects

Cache::get('key') looks like a static method call, but Cache is a facade: a tiny class that forwards static calls to an object in the container.

namespace Illuminate\Support\Facades;

class Cache extends Facade
{
    protected static function getFacadeAccessor(): string
    {
        return 'cache';           // the container key
    }
}

// The base Facade class, simplified:
abstract class Facade
{
    public static function __callStatic(string $method, array $args): mixed
    {
        $instance = app(static::getFacadeAccessor());  // resolve from container

        return $instance->$method(...$args);           // forward the call
    }
}

So these three lines do exactly the same thing:

Cache::get('projects.count');                 // facade
cache()->get('projects.count');               // helper function
app('cache')->get('projects.count');          // container directly

And because it's still a container resolution, facades are easy to fake in tests. The demo test suite swaps the real event dispatcher and queue for fakes, then asserts on what was recorded:

tests/Feature/Demo/TaskCompletionTest.php

public function test_completing_a_task_dispatches_the_event_once(): void
{
    Event::fake([TaskCompleted::class]);
    $task = Task::factory()->create();

    $task->markComplete();
    $task->markComplete();

    $this->assertNotNull($task->fresh()->completed_at);
    Event::assertDispatchedTimes(TaskCompleted::class, 1);
    Event::assertDispatched(TaskCompleted::class, fn (TaskCompleted $event) => $event->task->is($task));
}

public function test_listener_runs_on_the_queue(): void
{
    Queue::fake();
    Task::factory()->create()->markComplete();

    Queue::assertPushed(CallQueuedListener::class, fn (CallQueuedListener $job) => $job->class === LogTaskCompletion::class);
}

Event::fake() works by calling Event::swap($fake), which re-binds the events key in the container (app()->instance('events', $fake)). That replaces the object behind the facade, so every TaskCompleted::dispatch() in application code now talks to the fake.

Facades vs injection

Facade Cache::get() Injected CacheRepository $cache
Brevity ✅ Very short More ceremony
Discoverability Dependencies hidden in the method body ✅ Visible in the constructor
Testing ✅ ::fake() / ::shouldReceive() ✅ Pass a mock
Framework coupling Coupled to Laravel Can depend on an interface (PSR)
Class growing too large? Easy to miss ✅ A long constructor is a warning sign

Real-time facades turn any class into a facade by prefixing its namespace: use Facades\App\Services\SiteConfigService; then SiteConfigService::get(). They're handy in Blade or quick scripts, but injection is usually clearer in application code.

Common pitfalls

  • Resolving in register(): calling app(SomeService::class) inside register() may resolve before another provider has bound it. Do it in boot() or inside a closure.
  • Stateful singletons under Octane or queues: a singleton that stores the current user leaks it to the next request handled by the same worker. Use scoped() instead.
  • new inside business logic: new StripeGateway() in a controller bypasses the container, so tests can't swap it.
  • Primitive constructor args: __construct(string $apiKey) can't be auto-wired. Bind a factory closure, or use the #[Config('services.x.key')] contextual attribute.

Illustrative:

use Illuminate\Container\Attributes\Config;

class WeatherClient
{
    public function __construct(#[Config('services.weather.key')] private string $apiKey) {}
}

Key takeaways

  • The container builds objects and their dependencies, using reflection to auto-wire concrete classes.
  • Bind interfaces, scalar-configured classes and shared services in a provider's register() method.
  • bind gives a new instance every time, singleton builds once, and scoped resets per request or job.
  • Providers register first, then boot. Use other services only in boot().
  • Facades are static proxies to container services, which keeps them fakeable in tests.