Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Building Web Apps with Laravel  Module 11 – Inside the framework

The service container and dependency injection

Let Laravel 13's container build your classes, bind interfaces by environment, choose bind, singleton or scoped, inject config and swap fakes in tests.

  • Intermediate
  • 15 minutes
  • Examples run with PHP 8.5.11, Composer 2.10.3, Laravel 13.34.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Explain what the container builds on its own, and why an interface or a string parameter needs a binding or a value
  • Choose between bind, singleton and scoped for a service, and predict which object each queued job receives
  • Configure a contextual binding and the
  • Swap a real service for a fake or a Mockery mock in a feature test

Before you start

What you’ll learn · Before you start

What you will learn

  • Explain what the container builds on its own, and why an interface or a string parameter needs a binding or a value
  • Choose between bind, singleton and scoped for a service, and predict which object each queued job receives
  • Configure a contextual binding and the
  • Swap a real service for a fake or a Mockery mock in a feature test

Before you start

On this page

Pathshala sends a fee reminder from three places: a button in the back office, a nightly command and a queued job. If each of the three builds its own FeeReminder with new, each also builds the formatter and the SMS gateway inside it, so a change of SMS provider means editing three files, and a test has no way to stop real messages going out. Laravel’s service container: The object that builds and stores the classes an application needs. Laravel's application object is the container, and providers put their bindings into it. More in “How Laravel handles a request” builds that chain from the classes’ own constructors and hands it to whoever asks.

This lesson runs the container on Pathshala’s classes. You will see what it builds with no setup and the two things that stop it, bind an interface by environment, measure how long an object lives across three jobs of a real queue worker, give one class a different implementation from the rest, and replace the SMS gateway with a fake in a test.

The container builds a class by reading its constructor

Ask the container for a class, with app(FeeStatement::class) or by typing the class in a controller’s constructor, and it reads the constructor’s parameters through PHP’s reflection API. For each parameter typed with a class, it builds that class first, in the same way, and then calls the constructor with the results. This is autowiring: The container's way of building a class with no instructions: it reads the constructor with reflection, builds each parameter typed with a concrete class the same way, then calls the constructor. Laravel's documentation calls it zero-configuration resolution., which the Laravel documentation calls zero-configuration resolution: a class whose constructor asks only for other concrete classes needs no setup at all.

The probe below asks for three of Pathshala’s classes, none of them registered anywhere. A callback prints every object of the app as the container builds it:

Three classes asked for, nothing registered Python · autowire_session.py
"""Zero-configuration resolution: the container builds classes from their constructors, and stops at an interface or a
plain string it has no instructions for. The probe and the four classes are in the folder autowire/ next to this file."""

from session import run

run(
    """
    php autowire_probe.php
    """,
    app=True,
    overlay=("autowire",),
)

Output

$ php autowire_probe.php
make(FeeStatement)
  built App\Services\ReceiptFormatter
  built App\Services\FeeStatement
  result: Asha Verma: INR 1,180.00 due
make(FeeReminder)
  built App\Services\ReceiptFormatter
  BindingResolutionException: Target [App\Contracts\SmsGateway] is not instantiable while building [App\Services\FeeReminder].
make(HttpSmsGateway)
  BindingResolutionException: Unresolvable dependency resolving [Parameter #0 [ <required> string $senderId ]] in class App\Services\HttpSmsGateway

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 autowire_session.py

FeeStatement worked. The container built the ReceiptFormatter its constructor asks for, then the statement itself, which printed Asha Verma: INR 1,180.00 due. The other two requests stopped with a BindingResolutionException: The exception Laravel's container throws when it cannot build what was asked for, such as an interface with no binding or a string parameter with no value. Its message names the type and the class being built., and their two messages name the two things autowiring cannot do on its own.

FeeReminder asks for an SmsGateway, which is an interface. PHP cannot create an object of an interface, and nothing in it says which class to use, so the message reads Target [App\Contracts\SmsGateway] is not instantiable while building [App\Services\FeeReminder]. Look at the line above it: the formatter was built before the container reached the interface, and was then thrown away. HttpSmsGateway asks for string $senderId. A string has no class to build and the container cannot guess which text you mean, so it reports an unresolvable dependency at parameter #0. Step through the second request:

Interactive · stage by stage Building FeeReminder, up to the exception Step through the second request of the run, from the call to the exception it ended with.
What the container does for make(FeeReminder), step 6 of 6. Showing make(FeeReminder), Read the constructor, ReceiptFormatter (built), SmsGateway (bound to a class), Build FeeReminder. In focus: Build FeeReminder.no bindingmake(FeeReminder)a class nameRead the constructortwo typed parametersReceiptFormatterbuiltSmsGatewaybound to a classBuild FeeReminderwith both objects

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. A class is asked for. The probe calls make() with the FeeReminder class name. No provider binds it, so the container falls back to building the class itself.
  2. Reflection reads the constructor. PHP's reflection API lists the constructor's parameters and their types, a ReceiptFormatter and an SmsGateway. The container must have an object for each one before it can call new.
  3. The first parameter is easy. ReceiptFormatter is a concrete class with no constructor of its own, so the container builds one. The run printed this line, built App\Services\ReceiptFormatter, before anything failed.
  4. The second has no class. SmsGateway is an interface, and PHP cannot create an object of an interface. Without a binding the container has no class to build, so it stops here.
  5. The exception names both. The container throws a BindingResolutionException that names the interface and the class it was building. FeeReminder is never created, and the formatter built a moment earlier is thrown away.
  6. What a binding changes. Once a provider binds SmsGateway to a class, this step finds that class, builds it and moves on. The container then calls the constructor with both objects and returns the new FeeReminder.

Both failures are fixed by telling the container one more fact: a binding for the interface, and a value for the string. The next sections add each. Everything the framework builds for you goes down this same path: controllers, middleware, event listeners, the handle method of a queued job and the closure of an Artisan command all receive their parameters from the container.

Building a dependency with new inside the class

A constructor that runs new HttpSmsGateway('PTHSLA') itself hides the gateway from the container, so no binding and no test can ever replace it. Ask for the SmsGateway interface as a constructor parameter instead, and let a binding decide which class arrives.

An interface gets a binding that names its class

A container binding: A rule in the service container that says how to build a type, usually an interface: a class name or a closure, registered with bind(), singleton() or scoped() in a service provider, or written as a #[Bind] attribute on the interface. is a rule in the container that maps a type, usually an interface, to the class or the closure that builds it. The usual home is the register method of a service provider: A class that sets one part of the application up. Its register method binds services into the container, and its boot method uses them once every provider has registered. More in “How Laravel handles a request”, which runs before anything asks for a service; the request lesson shows why register comes before boot. Pathshala’s AppServiceProvider gets one line, $this->app->bind(SmsGateway::class, LogSmsGateway::class). From then on every constructor that asks for an SmsGateway receives a LogSmsGateway, which writes each message to the log instead of sending it.

Laravel 13 can also keep the binding on the interface itself. A #[Bind] PHP attribute: Metadata written in square brackets above a class, method or property, which the framework reads to change behaviour without a method or a property being written. More in “Form requests, custom rules and Precognition” names the class, and an optional environments: list limits it to the values of APP_ENV it names. Pathshala’s PaymentGateway carries two of them, a sandbox for local and testing and the live client for production:

A binding in a provider, and two bindings on an interface

binding/app/Providers/AppServiceProvider.php

<?php

namespace App\Providers;

use App\Contracts\SmsGateway;
use App\Services\LogSmsGateway;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Whenever a constructor asks for an SmsGateway, build a LogSmsGateway.
        $this->app->bind(SmsGateway::class, LogSmsGateway::class);
    }

    public function boot(): void
    {
        //
    }
}

binding/app/Contracts/PaymentGateway.php

<?php

namespace App\Contracts;

use App\Services\LivePaymentGateway;
use App\Services\SandboxPaymentGateway;
use Illuminate\Container\Attributes\Bind;

// The binding lives on the interface itself: the container reads these attributes the first time something asks for
// a PaymentGateway, and picks the line whose environments include the app's current one.
#[Bind(SandboxPaymentGateway::class, environments: ['local', 'testing'])]
#[Bind(LivePaymentGateway::class, environments: ['production'])]
interface PaymentGateway
{
    public function charge(int $paise, string $reference): string;
}

The probe resolves both in three environments. The session sets APP_ENV in the shell, which takes precedence over the value in .env:

One probe with APP_ENV local, production and staging Python · binding_session.py
"""Bindings: AppServiceProvider binds SmsGateway to LogSmsGateway, and PaymentGateway carries #[Bind] attributes that
pick a class by environment. The same probe runs in three environments. Files: the folder binding/ next to this file."""

from session import run

run(
    """
    php binding_probe.php
    export APP_ENV=production
    php binding_probe.php
    export APP_ENV=staging
    php binding_probe.php
    """,
    app=True,
    overlay=("binding",),
)

Output

$ php binding_probe.php
environment: local
  FeeReminder sends through LogSmsGateway
  PaymentGateway is SandboxPaymentGateway
$ export APP_ENV=production
$ php binding_probe.php
environment: production
  FeeReminder sends through LogSmsGateway
  PaymentGateway is LivePaymentGateway
$ export APP_ENV=staging
$ php binding_probe.php
environment: staging
  FeeReminder sends through LogSmsGateway
  BindingResolutionException: Target [App\Contracts\PaymentGateway] is not instantiable.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 binding_session.py

In local the reminder sends through LogSmsGateway and the payment gateway is the sandbox. With APP_ENV=production the provider’s binding is unchanged, and the attributes pick LivePaymentGateway. With APP_ENV=staging no attribute matches, and the interface is back to is not instantiable: an environment that the list forgot fails at its first payment, not at deploy time. Give such environments a default with a #[Bind] line that has no environments: list, or name every environment you deploy to.

Key idea

The container builds concrete classes by itself. An interface needs a binding, in a provider’s register method or as #[Bind] attributes on the interface, and a plain value such as a string needs to be told where it comes from.

Checkpoint

Check: What the container builds on its own · Binding an interface, in a provider or with

  1. Question 1 of 2 Nothing is registered. FeeReminder asks for a ReceiptFormatter (a class) and an SmsGateway (an interface). What does app(FeeReminder::class) do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It builds the formatter, then throws a BindingResolutionException because the interface has no class

    Autowiring builds class-typed parameters one by one. The first is a concrete class and is built; the second is an interface, which has no class to build, so the container throws a BindingResolutionException naming SmsGateway and FeeReminder.

  2. Question 2 of 2 PaymentGateway carries #[Bind(SandboxPaymentGateway::class, environments: ['local', 'testing'])] and #[Bind(LivePaymentGateway::class, environments: ['production'])]. The app runs with APP_ENV=staging. What does app(PaymentGateway::class) return?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Nothing; it throws because PaymentGateway is not instantiable

    The container reads the #[Bind] attributes and picks the one whose environments include the current APP_ENV. With staging, none does, so the interface stays unbound and the container throws. A #[Bind] line without an environments list gives every other environment a default.

Bind, singleton and scoped decide how long an object lives

A binding also decides how long its object lives. bind() builds a new object every time something asks. A singleton: A binding whose object is built the first time something asks for it and then shared until the process ends, registered with singleton() or the #[Singleton] attribute. In a queue worker every job of the worker shares it., registered with singleton(), is built on the first ask and then returned for as long as the application lives. A scoped instance: An object the container shares within one request or one queued job and drops before the next one, registered with scoped() or the #[Scoped] attribute. Queue workers and Octane reset these between units of work. object, registered with scoped(), sits between the two: one object per request or per queued job, dropped before the next one starts. The attributes #[Singleton] and #[Scoped] on a class say the same without a line in a provider, and a class that was never registered behaves like bind().

Under PHP-FPM, the usual way PHP runs behind a web server, the application is built again for every request, so a singleton lives for one request and the difference from scoped never shows. It shows in a process that stays alive. A queue worker runs job after job in one booted application, and so does Octane for requests. The run below starts a real worker on three jobs. Each job asks twice for each service and records a letter for the object it got, the same letter for the same object:

Three lifetimes, and the job that asks for them

lifetimes/app/Providers/AppServiceProvider.php

<?php

namespace App\Providers;

use App\Services\CentreDirectory;
use App\Services\JobLedger;
use App\Services\ReceiptFormatter;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(ReceiptFormatter::class);      // a new object every time (also what an unbound class gets)
        $this->app->singleton(CentreDirectory::class);  // one object for as long as the process runs
        $this->app->scoped(JobLedger::class);           // one object per request or per queued job
    }

    public function boot(): void
    {
        //
    }
}

lifetimes/app/Jobs/RecordLifetimes.php

<?php

namespace App\Jobs;

use App\Services\CentreDirectory;
use App\Services\JobLedger;
use App\Services\ReceiptFormatter;
use App\Support\Letters;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

// Asks the container twice for each of the three services and writes down which object it got each time.
class RecordLifetimes implements ShouldQueue
{
    use Queueable;

    public function __construct(public int $number)
    {
    }

    public function handle(): void
    {
        $twice = fn (string $class) => Letters::of(app($class)).' '.Letters::of(app($class));

        $line = sprintf(
            'job %d   bind: %s   singleton: %s   scoped: %s',
            $this->number,
            $twice(ReceiptFormatter::class),
            $twice(CentreDirectory::class),
            $twice(JobLedger::class),
        );

        file_put_contents(storage_path('app/lifetimes.txt'), $line.PHP_EOL, FILE_APPEND);
    }
}
php artisan queue:work on three jobs Python · lifetimes_session.py
"""Lifetimes in a real queue worker: three jobs, each asking the container twice for a bind(), a singleton() and a
scoped() service. Letters stand for objects (the same object, the same letter). Files: the folder lifetimes/."""

from session import run

run(
    """
    php lifetimes_probe.php
    php artisan queue:work --stop-when-empty --quiet
    cat storage/app/lifetimes.txt
    """,
    app=True,
    overlay=("lifetimes",),
)

Output

$ php lifetimes_probe.php
jobs waiting on the queue: 3
$ php artisan queue:work --stop-when-empty --quiet
$ cat storage/app/lifetimes.txt
job 1   bind: A B   singleton: C C   scoped: D D
job 2   bind: E F   singleton: C C   scoped: G G
job 3   bind: H I   singleton: C C   scoped: J J

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lifetimes_session.py

Read the output column by column. bind() handed out six formatters for six asks, A to I without one repeat. The singleton is C in every job: the worker built one CentreDirectory and shared it with all three. The scoped ledger is one object within a job, D and D, and a new object in each job, D, G and J. The worker drops it on purpose: before it takes each job it calls forgetScopedInstances() on the container, as the 13.x source of QueueServiceProvider shows. Step through the same run:

Interactive · step by step One worker, three jobs, three lifetimes Step through the worker's three jobs. Each service shows the letters of the objects the job received.
Three jobs, three lifetimes, one worker, step 5 of 5. Showing Queue worker, Current job (job 3), ReceiptFormatter (H, then I), CentreDirectory (C (the same)), JobLedger (J). In focus: Current job.Queue workerone PHP processCurrent jobjob 3ReceiptFormatterH, then ICentreDirectoryC (the same)JobLedgerJ

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The worker starts. php artisan queue:work boots the application once and keeps it in memory. Every job it takes runs inside this one process, with this one container.
  2. Job 1 asks twice for each. The first job asks the container for each service two times. bind() hands out a new formatter on every ask, A and then B; the singleton is C; the scoped ledger is D both times.
  3. The worker resets the scope. Before it takes the next job, the worker calls forgetScopedInstances(). The container drops the scoped ledger D and keeps the singleton C.
  4. Job 2 gets a new ledger. The second job receives E and F from bind() and the same C from the singleton. The scoped ledger is a new object, G, shared by both of its asks.
  5. Job 3 repeats the pattern. The third job gets H and I, C again, and a fresh ledger J. Across the three jobs the run printed six formatters, one directory and three ledgers.

Choose a lifetime from what the object holds. A service that only reads settings or computes may be a singleton when it is costly to build, and needs no registration when it is cheap. A service that holds anything about the current request or job must never be a singleton. Answer the two questions for one of your own services:

Interactive · answer and compare Choosing a lifetime for a service Answer the two questions about one of your services. The table marks the lifetimes that fit, and says why the others do not.
Does the object keep data about one request or one job?
Is it costly to build, or should the code inside one job share one copy?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Three container lifetimes against what a long-running worker does with each
Lifetime Holds per-job state Costly to build In a long-running worker
New each time Registered with: bind(), or no registration at all Good every ask gets a fresh object, so nothing carries over Poor built again on every ask: the run made 6 formatters for 3 jobs Good nothing outlives the code that asked for it
One per process Registered with: singleton(), or #[Singleton] on the class Poor one object for every job: the run leaked Nehru Nagar into the summary Good built once, on the first ask: one directory, C, for 3 jobs Mixed lives as long as the worker, so it must hold nothing about one job
One per request or job Registered with: scoped(), or #[Scoped] on the class Good dropped between jobs: the ledgers were D, G and J Mixed built once per job, not once per ask Good the worker resets it before each job
How the answers decide
  • “Does the object keep data about one request or one job?” Yes, such as the current centre or a running total: rules out One per process. A singleton outlives the job that filled it, so the next job of the same worker reads the old value.
  • “Does the object keep data about one request or one job?” Yes, such as the current centre or a running total and “Is it costly to build, or should the code inside one job share one copy?” Yes, build it once and share it: adds a caveat to New each time. A new object on every ask cannot be shared by two classes inside one job; scoped gives one copy per job.
  • “Does the object keep data about one request or one job?” No, it only reads settings or computes and “Is it costly to build, or should the code inside one job share one copy?” Yes, build it once and share it: adds a caveat to New each time and One per request or job. With nothing about one job inside it, a single shared copy is safe, and building it again is wasted work.
  • “Does the object keep data about one request or one job?” No, it only reads settings or computes and “Is it costly to build, or should the code inside one job share one copy?” No, a fresh one each time is fine: adds a caveat to One per process and One per request or job. A small object with no state needs no registration at all: the default builds a new one each time.

Key idea

A singleton lives as long as the application that built it, and a scoped object as long as one request or job. In a queue worker, which keeps one application for every job, that difference decides whether one job can see the data of another.

A singleton that holds job state leaks into the next job

The danger in that table is easy to write by accident. CurrentCentre remembers which centre the current work is about, so that code deep inside a job can ask for it instead of passing the centre through every call. It was marked #[Singleton] so that every class inside a job would share one object. The reminders job sets it to Nehru Nagar, and the daily summary, which should cover all three centres, runs next in the same worker:

A centre holder shared too widely, and the job that reads it

leak/app/Support/CurrentCentre.php

<?php

namespace App\Support;

use Illuminate\Container\Attributes\Singleton;

// Which centre the current piece of work is about. Marked #[Singleton]: one object for the whole worker process.
#[Singleton]
class CurrentCentre
{
    private ?string $name = null;

    public function set(string $name): void
    {
        $this->name = $name;
    }

    public function name(): ?string
    {
        return $this->name;
    }
}

leak/app/Jobs/SendDailySummary.php

<?php

namespace App\Jobs;

use App\Support\CurrentCentre;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

// The daily summary covers every centre, unless some code has narrowed the work down to one.
class SendDailySummary implements ShouldQueue
{
    use Queueable;

    public function handle(CurrentCentre $current): void
    {
        $line = $current->name() === null
            ? 'summary job: no centre set, so it covers all 3 centres'
            : "summary job: covers only {$current->name()}";
        file_put_contents(storage_path('app/centre.log'), $line.PHP_EOL, FILE_APPEND);
    }
}
The two jobs with #[Singleton], then with #[Scoped] Python · leak_session.py
"""A singleton that holds the state of one job leaks it into the next job of the same worker. The session runs the
two jobs with CurrentCentre marked #[Singleton], copies in the version marked #[Scoped], and runs them again.
Files: the folder leak/ next to this file (leak/fixed/ holds the scoped version)."""

from session import run

run(
    """
    php leak_probe.php
    php artisan queue:work --stop-when-empty --quiet
    cat storage/app/centre.log
    cp fixed/app/Support/CurrentCentre.php app/Support/CurrentCentre.php
    php leak_probe.php
    php artisan queue:work --stop-when-empty --quiet
    cat storage/app/centre.log
    """,
    app=True,
    overlay=("leak",),
)

Output

$ php leak_probe.php
queued: reminders for Nehru Nagar, then the daily summary
$ php artisan queue:work --stop-when-empty --quiet
$ cat storage/app/centre.log
reminders job: centre set to Nehru Nagar
summary job: covers only Nehru Nagar
$ cp fixed/app/Support/CurrentCentre.php app/Support/CurrentCentre.php
$ php leak_probe.php
queued: reminders for Nehru Nagar, then the daily summary
$ php artisan queue:work --stop-when-empty --quiet
$ cat storage/app/centre.log
reminders job: centre set to Nehru Nagar
summary job: no centre set, so it covers all 3 centres

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 leak_session.py

With #[Singleton] the summary job printed covers only Nehru Nagar. The object the first job filled was still in the container, so a report meant for every centre quietly covered one. The bug hides in two places: on a laptop, where each job may run in its own php artisan call, and under PHP-FPM, where every request starts empty. It appears only in a long-running process, which is exactly where queued jobs run in production.

After the session copies in the version marked #[Scoped], the same two jobs print no centre set, so it covers all 3 centres. Nothing else changed: the worker dropped the first job’s object before the second job began, as it did with the ledger in the previous run.

Clearing the value at the end of the job instead

A line that resets CurrentCentre at the end of handle() works until a job throws before it reaches that line, and then the next job inherits the stale centre. Mark the class #[Scoped] and let the worker’s reset clear it before every job, whether the last one succeeded or failed.

Checkpoint

Check: bind, singleton and scoped lifetimes · State in a singleton leaking between jobs

  1. Question 1 of 2 A queue worker runs 4 jobs. Each job asks the container twice for JobLedger, which is registered with scoped(). How many JobLedger objects does the worker build?

    Type a number.

    A hint

    Count per job, then think about what the worker does between jobs.

    Show the answer: Question 1 of 2

    Answer: 4 objects

    A scoped object is shared within one job, so both asks inside a job get the same ledger, and the worker drops it before the next job, so each job gets a new one: 4 jobs, 4 ledgers. In the recorded run, 3 jobs made the ledgers D, G and J.

  2. Question 2 of 2 A Basket service collects the line items of one invoice while a queued job builds it, and several classes inside the job add to it. It is registered with singleton(), and the second invoice of a worker shows the first invoice's items too. Which change fixes it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Register Basket with scoped(), so each job gets its own basket that the classes of that job share

    The basket holds the state of one job and must be shared inside that job only. That is the definition of a scoped object, which the queue worker resets before each job, as it did for CurrentCentre in the recorded run.

Contextual bindings and attributes give one class what it needs

Sometimes one class needs a different implementation from everyone else. Pathshala’s sign-in codes must reach a parent within seconds, while fee reminders can wait in a cheap bulk route. Both FeeReminder and OtpSender ask for an SmsGateway, and a contextual binding: A binding that applies only while the container builds one named class, such as when(OtpSender::class)->needs(SmsGateway::class)->give(...), or #[Give] on the parameter. For that class it wins over the plain binding. gives each its own: $this->app->when(OtpSender::class)->needs(SmsGateway::class)->give(PrioritySmsGateway::class). The #[Give] attribute writes the same rule on the parameter, as ResetCodeSender does below. When the container builds a class, it checks for such a rule before it looks at the plain binding:

Interactive · step by step Three classes, one interface, two routes Step through the three classes of the run that ask for an SmsGateway, and see which rule picks each one's class.
Three classes ask for one interface, step 4 of 4. Showing FeeReminder, OtpSender, ResetCodeSender, Bulk route, Priority route.FeeReminderplain bindingOtpSenderwhen()->needs()ResetCodeSender#[Give]Bulk routeBulkSmsGatewayPriority routePrioritySmsGateway

The drawing shows the whole scene; its 4 steps are listed below.

Every step as text
  1. The default binding. FeeReminder asks for an SmsGateway. No rule names FeeReminder, so the container uses the plain binding of AppServiceProvider and builds a BulkSmsGateway.
  2. A rule for one class. OtpSender asks for the same interface. The provider's when(OtpSender)->needs(SmsGateway) rule matches this class, and the container checks that rule before the plain binding, so OtpSender gets a PrioritySmsGateway.
  3. The rule on the parameter. ResetCodeSender writes the same choice as
  4. One interface, two classes. All three constructors type the same interface, and none of them names a class. The bindings decide, so the route a message takes can change without editing any of the three.

Laravel 13 has a family of these container attribute: A PHP attribute on a constructor or method parameter that tells Laravel's container where the value comes from, such as #[Config('key')], #[CurrentUser], #[Storage('disk')] or #[Give(SomeClass::class)]. for parameters, so a constructor can say where each value comes from. #[Config('services.sms.sender_id')] string $senderId fills a string from a config file, which fixes the third failure of the first probe. #[CurrentUser] User $author injects the signed-in user. The others follow the same pattern. #[Storage('receipts')] injects a filesystem disk, and #[Cache], #[DB] and #[Log] a named store, connection or channel. #[Context('request_id')] reads a value of the request’s context, #[RouteParameter('student')] a route parameter, and #[Tag('reports')] every service tagged with that name. The run resolves the classes of this section:

A contextual binding, and four parameter attributes

contextual/app/Providers/AppServiceProvider.php

<?php

namespace App\Providers;

use App\Contracts\SmsGateway;
use App\Services\BulkSmsGateway;
use App\Services\OtpSender;
use App\Services\PrioritySmsGateway;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Every class that asks for an SmsGateway gets the bulk route ...
        $this->app->bind(SmsGateway::class, BulkSmsGateway::class);

        // ... except OtpSender: sign-in codes must not wait behind a batch of fee reminders.
        $this->app->when(OtpSender::class)
            ->needs(SmsGateway::class)
            ->give(PrioritySmsGateway::class);
    }

    public function boot(): void
    {
        //
    }
}

contextual/app/Services/ResetCodeSender.php

<?php

namespace App\Services;

use App\Contracts\SmsGateway;
use Illuminate\Container\Attributes\Give;

// Sends password-reset codes. The same choice as OtpSender, written on the parameter: #[Give] names the class.
class ResetCodeSender
{
    public function __construct(
        #[Give(PrioritySmsGateway::class)] private SmsGateway $sms,
    ) {
    }

    public function describe(): string
    {
        return 'ResetCodeSender sends through '.class_basename($this->sms);
    }
}

contextual/app/Services/HttpSmsGateway.php

<?php

namespace App\Services;

use App\Contracts\SmsGateway;
use Illuminate\Container\Attributes\Config;

// The class that stopped the container earlier: #[Config] now tells it where the string comes from.
class HttpSmsGateway implements SmsGateway
{
    public function __construct(
        #[Config('services.sms.sender_id')] private string $senderId,
    ) {
    }

    public function send(string $to, string $text): string
    {
        return "queued at the provider as {$this->senderId}";
    }

    public function describe(): string
    {
        return "HttpSmsGateway sends as {$this->senderId}";
    }
}

contextual/app/Services/StaffNotice.php

<?php

namespace App\Services;

use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;

// Puts the name of whoever is signed in on a notice: #[CurrentUser] injects the authenticated user.
class StaffNotice
{
    public function __construct(#[CurrentUser] private User $author)
    {
    }

    public function describe(): string
    {
        return $this->author->exists
            ? "notice from {$this->author->name}"
            : 'notice from an empty User the container built (name: '.var_export($this->author->name, true).')';
    }
}
Routes, a config value and the signed-in user Python · contextual_session.py
"""Contextual binding and parameter attributes: OtpSender gets another SmsGateway than every other class, #[Give]
does the same on a parameter, #[Config] fills a string from config/services.php and #[CurrentUser] injects the
signed-in user. Files: the folder contextual/ next to this file."""

from session import run

run(
    """
    php contextual_probe.php
    """,
    app=True,
    overlay=("contextual",),
)

Output

$ php contextual_probe.php
FeeReminder sends through BulkSmsGateway
OtpSender sends through PrioritySmsGateway
ResetCodeSender sends through PrioritySmsGateway
HttpSmsGateway sends as PTHSLA
nobody signed in
  constructor, User:   notice from an empty User the container built (name: NULL)
  constructor, ?User:  null
  method call, ?User:  null
Asha signed in
  constructor, User:   notice from Asha Verma
  constructor, ?User:  a User, exists: true
  method call, ?User:  a User, exists: true

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 contextual_session.py

The first four lines are the routes and the sender id: the bulk route for FeeReminder, the priority route for both code senders, and PTHSLA, the value of services.sms.sender_id in this project’s config/services.php. The two blocks after them hold the surprise of the run. With nobody signed in, #[CurrentUser] has no user to give, and a constructor parameter typed User does not fail: the container falls back to building a new, empty User, and the notice names an author whose name is NULL. A constructor parameter written ?User $author = null receives its default, null, and so does a nullable parameter of a method that the container calls, such as a controller action. Once Asha is signed in, all three receive her saved record.

A non-nullable #[CurrentUser] User in a constructor

When nobody is signed in, #[CurrentUser] User $author in a constructor becomes an empty model that passes every type check. Write #[CurrentUser] ?User $author = null and handle null, or build the class only on routes behind the auth middleware.

A test swaps the binding for a fake or a mock

The bindings are also what make a class easy to test. A feature test runs inside the same container as the app, so it can put an object of its own in place of the real one before the code under test asks for it. $this->instance(SmsGateway::class, $fake) stores the fake as a ready-made instance, which wins over the provider’s bind() or singleton() of that type but not over a contextual binding, so OtpSender would still get its priority gateway. $this->swap() does the same: in 13.34 it simply calls instance(). $this->mock(SmsGateway::class, ...) builds a Mockery mock and binds it in one step, so the test can state the exact call it expects.

Pathshala’s test does both against a fees:remind command, whose closure receives its FeeReminder from the container:

The command, the fake and the two tests

swap/tests/Feature/FeeReminderTest.php

<?php

namespace Tests\Feature;

use App\Contracts\SmsGateway;
use App\Services\FeeReminder;
use Mockery\MockInterface;
use Tests\Fakes\FakeSmsGateway;
use Tests\TestCase;

class FeeReminderTest extends TestCase
{
    public function test_the_command_sends_one_message_per_due_instalment(): void
    {
        // Put the fake into the container before the command asks for an SmsGateway.
        $fake = new FakeSmsGateway();
        $this->instance(SmsGateway::class, $fake);

        $this->artisan('fees:remind')
            ->expectsOutput('Asha Verma: fake-1')
            ->assertSuccessful();

        $this->assertCount(2, $fake->sent);
        $this->assertSame('+91 9800000002', $fake->sent[1]['to']);
        $this->assertSame('Kabir Singh: INR 950.00 due', $fake->sent[1]['text']);
    }

    public function test_a_mock_checks_the_exact_call(): void
    {
        // mock() builds a Mockery mock and binds it in the container in one step.
        $this->mock(SmsGateway::class, function (MockInterface $mock) {
            $mock->shouldReceive('send')
                ->once()
                ->with('+91 9800000001', 'Asha Verma: INR 1,180.00 due')
                ->andReturn('mock-1');
        });

        $this->assertSame('mock-1', app(FeeReminder::class)->remind('+91 9800000001', 'Asha Verma', 118000));
    }
}

routes/console.php · swap/routes/console.php

<?php

use App\Services\FeeReminder;
use Illuminate\Support\Facades\Artisan;

// Sends a reminder for each instalment that is due. A closure command gets its parameters from the container.
Artisan::command('fees:remind', function (FeeReminder $reminder) {
    $due = [
        ['phone' => '+91 9800000001', 'student' => 'Asha Verma', 'paise' => 118000],
        ['phone' => '+91 9800000002', 'student' => 'Kabir Singh', 'paise' => 95000],
    ];
    foreach ($due as $row) {
        $id = $reminder->remind($row['phone'], $row['student'], $row['paise']);
        $this->line("{$row['student']}: {$id}");
    }
})->purpose('Send fee reminders for the instalments that are due');

swap/tests/Fakes/FakeSmsGateway.php

<?php

namespace Tests\Fakes;

use App\Contracts\SmsGateway;

// A test double: keeps every message in an array instead of sending it, so a test can read what was sent.
class FakeSmsGateway implements SmsGateway
{
    /** @var list<array{to: string, text: string}> */
    public array $sent = [];

    public function send(string $to, string $text): string
    {
        $this->sent[] = ['to' => $to, 'text' => $text];

        return 'fake-'.count($this->sent);
    }
}
php artisan fees:remind, then php artisan test Python · swap_session.py
"""Swapping in tests: the real command sends through LogSmsGateway (bound in AppServiceProvider); the feature test
puts a fake into the container with instance(), and a Mockery mock with mock(). Files: the folder swap/."""

from session import run

run(
    """
    export COLLISION_IGNORE_DURATION=true
    php artisan fees:remind
    php artisan test --filter=FeeReminderTest
    """,
    app=True,
    overlay=("swap",),
)

Output

$ export COLLISION_IGNORE_DURATION=true
$ php artisan fees:remind
Asha Verma: logged
Kabir Singh: logged
$ php artisan test --filter=FeeReminderTest
   PASS  Tests\Feature\FeeReminderTest
  ✓ the command sends one message per due instalment
  ✓ a mock checks the exact call

  Tests:    2 passed (7 assertions)
  Duration: …s

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 swap_session.py

Run for real, the command printed logged twice, because AppServiceProvider binds the log gateway. In the first test the same command sent both reminders to the fake, and the test read them back: two messages, the second to +91 9800000002 with the text Kabir Singh: INR 950.00 due. The second test asserted a call rather than a result. The mock expected exactly one send with Asha’s number and text, and Mockery would have failed the test on a second call or on any other arguments. Both tests passed, with 7 assertions between them, and neither one reached a real SMS provider.

Checkpoint

Check: Contextual bindings and parameter attributes · Swapping a binding in a test

  1. Question 1 of 2 SmsGateway is bound to BulkSmsGateway, and the provider adds when(OtpSender::class)->needs(SmsGateway::class)->give(PrioritySmsGateway::class). Which gateway does FeeReminder receive?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: BulkSmsGateway

    When it builds a class, the container first looks for a contextual rule for that class and that type, then falls back to the plain binding. FeeReminder has no rule of its own, so it gets the bulk route.

  2. Question 2 of 2 A feature test calls $this->instance(SmsGateway::class, $fake) and then runs the fees:remind command with $this->artisan(). What does the command's FeeReminder send through?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The fake, because the command's FeeReminder is built from the container after the instance was stored

    instance() stores a ready-made object for SmsGateway, which takes precedence over a bind() of that type. The command resolves FeeReminder when it runs, so it receives the fake, and the test then read both messages back from it.

Practise

Wire a payment gateway through the container in your own Pathshala project, one step at a time:

  1. Create the interface App\Contracts\PaymentGateway with charge(int $paise, string $reference): string, and two classes that implement it, SandboxPaymentGateway and CardPaymentGateway.
  2. Add 'payments' => ['driver' => env('PAYMENTS_DRIVER', 'sandbox')] to config/services.php. In AppServiceProvider::register, bind the interface with a closure that reads config('services.payments.driver') and builds the matching class.
  3. Give CardPaymentGateway its merchant id with #[Config('services.payments.merchant_id')], and check in php artisan tinker that app(PaymentGateway::class) follows PAYMENTS_DRIVER.
  4. Write a feature test that sets config(['services.payments.driver' => 'card']) and asserts the class it resolves, and a second test that puts a mock in the container which expects exactly one charge.

The self-check below picks each question from your earlier answers, so a part you missed comes back in another form.

Practice that adapts to you

Practise this lesson

12 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 12 Which constructor can the container fill with nothing registered at all?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: __construct(ReceiptFormatter $formatter, FeeStatement $statement)

    Autowiring needs a class it can instantiate for every parameter. Interfaces and plain values such as strings and integers need a binding, a default or an attribute.

  2. Question 2 of 12 A teammate's FeeReminder constructor takes no parameters and runs $this->sms = new HttpSmsGateway('PTHSLA'). What does that cost Pathshala?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: No binding and no test can replace the gateway, so a test of FeeReminder would try to send real messages

    Dependency injection works only for what a class asks for. A dependency the class builds with new is out of the container's reach, so the mistake callout asks for the SmsGateway interface as a parameter instead.

  3. Question 3 of 12 Where does a $this->app->bind(...) call for an interface belong?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: In the register method of a service provider, such as AppServiceProvider

    Providers' register methods are where bindings go, because all of them run before any boot method and before the first request asks for a service. A #[Bind] attribute on the interface is the alternative that needs no provider line.

  4. Question 4 of 12 Pathshala adds a staging server, and the payment gateway there should be the sandbox. What is the smallest change to the interface's attributes?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: Add 'staging' to the environments list of the sandbox

    Each #[Bind] attribute applies to the environments its list names. Adding staging to the sandbox line covers the new server; a #[Bind] line with no environments list would instead give a default to every unlisted environment.

  5. Question 5 of 12 Under PHP-FPM, how does a singleton() service differ from a scoped() one across two web requests?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: Not at all; each request builds the application again, so both give one object per request

    Lifetimes are measured within one booted application. PHP-FPM boots one per request, so singleton and scoped coincide there; a queue worker boots once and runs many jobs, which is where the recorded letters differ.

  6. Question 6 of 12 In the recorded run, 3 jobs each asked twice for the ReceiptFormatter registered with bind(). How many formatter objects were built?

    Type a number.

    Show the answer: Question 6 of 12

    Answer: 6 objects

    bind() builds a new object on every ask: 3 jobs times 2 asks is 6, the letters A, B, E, F, H and I, with no letter used twice.

  7. Question 7 of 12 Why did the CurrentCentre leak not show while each job was tested in its own php artisan call?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: Each call is a new process with a new container, so the singleton never outlives one job

    A singleton lives as long as its process. One job per process hides any state it carries; a worker that runs the reminders job and then the summary job in one process exposes it, as the recorded run did.

  8. Question 8 of 12 Which single change turned the leaking run into the correct one?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: CurrentCentre's attribute changed from

    The fix was one attribute. A scoped object is shared inside one job and dropped before the next, so the summary job received a new CurrentCentre with no centre set.

  9. Question 9 of 12 Which parameter lets the container build HttpSmsGateway, whose constructor needs the sender id as a string?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: #[Config('services.sms.sender_id')] string $senderId

    #[Config] resolves a parameter from a config key, with an optional default. config/services.php reads the key from the environment, so the value can still differ per server.

  10. Question 10 of 12 StaffNotice has __construct(#[CurrentUser] User $author), and a scheduled command builds it at night, with nobody signed in. What does $author hold?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: A new, empty User model that the container built, with a null name

    In a constructor, a contextual attribute that returns nothing makes the container build the class instead. Writing ?User $author = null makes the parameter receive null, which the code can check, as the second line of each block in the run showed.

  11. Question 11 of 12 In Laravel 13.34, what is the difference between $this->swap($abstract, $object) and $this->instance($abstract, $object) in a test?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: None; swap() simply calls instance()

    The two names exist for readability. mock() is the helper that also builds the Mockery object and binds it in one call.

  12. Question 12 of 12 The mock expects send() once(), but the code under test calls send() twice with the same arguments. What happens?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: The test fails, because Mockery checks the expected number of calls

    A mock asserts calls, not results: the number of calls and their arguments are both part of the expectation, which is why the recorded test counted its Mockery expectation among its assertions.

Interview questions

Warm-up: what does Laravel’s service container do when a constructor asks for a class, and in which two cases does it need something from you?

It reads the constructor with PHP’s reflection API, builds every parameter typed with a concrete class in the same way, and then calls the constructor with the results. Nothing has to be registered for that, which the documentation calls zero-configuration resolution. It needs your help in two cases. A parameter typed with an interface has no class to build, so you bind the interface to a class in a service provider’s register method, or with a #[Bind] attribute on the interface. A parameter typed as a string, an integer or another plain value has nothing to build at all, so you give it a value: a default in the signature, a closure binding, a contextual give(), or an attribute such as #[Config('services.sms.sender_id')]. Without one, the container throws a BindingResolutionException that names the parameter and the class it was building.

Two questions take the container past this lesson’s examples:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    Pathshala's GuardianNotifier now takes AttendanceAlerts in its constructor, and AttendanceAlerts takes GuardianNotifier. Neither is bound anywhere. Say what happens when a controller asks for either one, how you would find the cause, and how you would restructure the two classes.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to staff

    Each of Pathshala's three centres has its own merchant account with a card provider, and one centre still uses the sandbox. One queued job charges instalments for any centre. Design how code gets the right PaymentGateway for a given centre, and say what a container binding alone cannot do here.

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. How does Laravel's service container build a class that was never registered?

    It reads the constructor's parameters through PHP's reflection API, builds each class-typed parameter first in the same way, then calls the constructor with the results. This is autowiring: a class that asks only for concrete classes needs no setup.

  2. Which two kinds of constructor parameter make autowiring stop with a BindingResolutionException?

    An interface, such as SmsGateway, because PHP cannot create one and nothing says which class to use (is not instantiable); and a plain value such as string $senderId, because there is no class to build (an unresolvable dependency).

  3. Where does an interface get its binding, and what does Laravel 13 add?

    Usually in a service provider's register method, such as $this->app->bind(SmsGateway::class, LogSmsGateway::class). Laravel 13 can also put Bind attributes on the interface, each with an optional environments list of APP_ENV values; an environment no attribute names fails at first use, so add one Bind line without a list as the default.

  4. How long does an object live under bind(), singleton() and scoped()?

    bind() builds a new object on every ask. singleton() builds it on the first ask and returns that object for as long as the application lives: one request under PHP-FPM, every job in a queue worker. scoped() keeps one object per request or queued job; a queue worker calls forgetScopedInstances() before taking each job.

  5. Why did a daily summary job cover only one centre in a queue worker?

    CurrentCentre was a singleton, so the centre an earlier job set was still in the container. Marked Scoped, the worker cleared it before the next job. Resetting it at the end of handle() fails as soon as a job throws before that line.

  6. How does one class get a different implementation of an interface from everyone else?

    A contextual binding: $this->app->when(OtpSender::class)->needs(SmsGateway::class)->give(PrioritySmsGateway::class), or the Give attribute on the parameter. The container checks for such a rule before the plain binding.

  7. What do the Config and CurrentUser parameter attributes inject, and what is the trap?

    Config('services.sms.sender_id') fills a string from a config file; CurrentUser injects the signed-in user. With nobody signed in, a parameter typed User receives a new, empty User instead of failing, so write ?User $author = null and handle null.

  8. How does a feature test replace a container binding?

    $this->instance(SmsGateway::class, $fake) stores a ready object that wins over the provider's bind() or singleton() but not over a contextual binding; swap() does the same; mock() builds a Mockery mock and binds it, so the test can state the exact calls it expects.

Key takeaways

  • The container builds a class by reading its constructor and building each class-typed parameter first, so a class that depends only on concrete classes needs no registration.
  • An interface needs a binding, in a provider’s register method or as #[Bind] attributes on the interface, and a string or another plain value needs a value, or the container throws a BindingResolutionException.
  • bind() gives a new object on every ask, singleton() one object per process and scoped() one per request or job, and a queue worker drops the scoped objects before every job it takes.
  • A singleton that holds the state of one job leaks it into the next job of the same worker, so state about the current job or request belongs in a scoped object.
  • A contextual binding or #[Give] gives one class its own implementation, #[Config] fills a plain value from config, and #[CurrentUser] in a constructor typed User receives an empty model when nobody is signed in.
  • A feature test replaces a binding with $this->instance(), $this->swap() or $this->mock(), so the code under test receives the fake through the container without changing a line of it.

Terms in this lesson

autowiring
The container's way of building a class with no instructions: it reads the constructor with reflection, builds each parameter typed with a concrete class the same way, then calls the constructor. Laravel's documentation calls it zero-configuration resolution.
BindingResolutionException
The exception Laravel's container throws when it cannot build what was asked for, such as an interface with no binding or a string parameter with no value. Its message names the type and the class being built.
container attribute
A PHP attribute on a constructor or method parameter that tells Laravel's container where the value comes from, such as #[Config('key')], #[CurrentUser], #[Storage('disk')] or #[Give(SomeClass::class)].
container binding
A rule in the service container that says how to build a type, usually an interface: a class name or a closure, registered with bind(), singleton() or scoped() in a service provider, or written as a #[Bind] attribute on the interface.
contextual binding
A binding that applies only while the container builds one named class, such as when(OtpSender::class)->needs(SmsGateway::class)->give(...), or #[Give] on the parameter. For that class it wins over the plain binding.
scoped instance
An object the container shares within one request or one queued job and drops before the next one, registered with scoped() or the #[Scoped] attribute. Queue workers and Octane reset these between units of work.
singleton
A binding whose object is built the first time something asks for it and then shared until the process ends, registered with singleton() or the #[Singleton] attribute. In a queue worker every job of the worker shares it.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.