Service Container, Service Providers, Dependency Injection & Annotations
How Laravel builds objects for you (the service container), where you register and configure services (service providers), how this matches Spring Boot's IoC container and annotations, and why it matters: swapping a payment gateway, testing with fakes, and keeping controllers small.
Running example: a PaymentGateway interface with Razorpay, Stripe and fake implementations
1. Dependency injection: the problem it solves
If a class creates its own dependencies (new RazorpayClient(...) inside CheckoutService) it is welded to them: you cannot switch to Stripe for international cards, and tests would hit the real gateway. Dependency injection means the class asks for what it needs, usually an interface, in its constructor, and something else supplies it.
That "something else" is an IoC (Inversion of Control) container. Laravel calls it the service container; Spring calls it the ApplicationContext. It knows how to build every service and its dependencies, recursively.
Without and with injection
// Tightly coupled
class CheckoutService {
public function pay(Order $order) {
$client = new RazorpayClient(env('RZP_KEY'), env('RZP_SECRET')); // hard to test, hard to change
return $client->charge($order->total_paise);
}
}
// Injected
interface PaymentGateway { public function charge(Order $order, string $idempotencyKey): PaymentResult; }
class CheckoutService {
public function __construct(private PaymentGateway $gateway, private OrderRepository $orders) {}
public function pay(Order $order, string $key): PaymentResult { return $this->gateway->charge($order, $key); }
}
2. The Laravel service container
When Laravel calls a controller, job, listener, command or middleware, it reads the type hints of the constructor and method parameters and asks the container for each one. Concrete classes are built automatically by reflection (auto-wiring). Interfaces need a binding that says which implementation to use.
Binding types: bind() creates a new instance every time; singleton() creates one and reuses it for the request (or the whole worker process under Octane and queue workers, so do not keep per-user state in singletons); scoped() is a singleton reset per request/job; instance() registers an existing object; when()->needs()->give() supplies different implementations to different consumers.
Bindings
$this->app->singleton(PaymentGateway::class, fn ($app) => new RazorpayGateway(
new RazorpayClient(config('services.razorpay.key'), config('services.razorpay.secret')),
$app->make(LoggerInterface::class),
));
// International orders use Stripe, domestic use Razorpay
$this->app->when(InternationalCheckoutService::class)->needs(PaymentGateway::class)->give(StripeGateway::class);
// Resolving manually (rarely needed; prefer constructor injection)
$gateway = app(PaymentGateway::class);
$gateway = $this->app->make(PaymentGateway::class);
Tests swap the real gateway for a fake
public function test_order_is_marked_paid_when_charge_succeeds(): void
{
$this->app->instance(PaymentGateway::class, new FakeGateway(succeeds: true));
$this->postJson('/api/v1/orders/ORD-187/pay', [], ['Idempotency-Key' => 'k1'])->assertOk();
$this->assertSame('paid', Order::firstWhere('number', 'ORD-187')->status);
}
3. Service providers: where the app is wired together
A service provider is a class that registers bindings and boots features. Every Laravel feature (database, queue, mail, routing) is loaded by a provider. Your own providers are listed in config/app.php (Laravel 9/10) or bootstrap/providers.php (11+).
register() runs first for all providers and must only bind things into the container: do not use other services there because they may not be registered yet. boot() runs after every provider is registered and is where you use services: define gates, rate limiters, macros, view composers, event listeners, model observers and Blade directives.
Deferred providers (implementing DeferrableProvider) are only loaded when one of their services is actually requested, which saves time on every request.
PaymentServiceProvider
class PaymentServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../../config/payments.php', 'payments');
$this->app->singleton(PaymentGateway::class, fn ($app) => match (config('payments.default')) {
'stripe' => $app->make(StripeGateway::class),
'fake' => new FakeGateway(),
default => $app->make(RazorpayGateway::class),
});
}
public function boot(): void
{
RateLimiter::for('payments', fn (Request $r) => Limit::perMinute(10)->by($r->user()?->id ?: $r->ip()));
Event::listen(PaymentCaptured::class, [SendPaymentReceipt::class, 'handle']);
Payment::observe(PaymentAuditObserver::class);
}
}
Laravel boot order
- public/index.php
- Create application (container)
- register() on all providers
- boot() on all providers
- Handle request through middleware and router
4. Spring Boot: the same ideas with annotations
Annotations are metadata attached to classes, methods or fields with @Name. They do nothing by themselves; a framework reads them (at startup via reflection, or at compile time) and changes behaviour. Spring Boot is driven almost entirely by annotations; PHP 8 has native attributes (#[Route], #[AsCommand]) and older PHP libraries used docblock annotations (/** @OA\Get */).
In Spring, @Component/@Service/@Repository/@Controller mark a class as a bean the container should create. Constructor injection supplies dependencies. @Configuration classes with @Bean methods play the role of Laravel service providers. @Primary and @Qualifier choose between several implementations, like when()->needs()->give().
Spring beans for the payment gateway
public interface PaymentGateway { PaymentResult charge(Order order, String idempotencyKey); }
@Service @Primary
class RazorpayGateway implements PaymentGateway { ... }
@Service("stripeGateway")
class StripeGateway implements PaymentGateway { ... }
@Service
class InternationalCheckoutService {
private final PaymentGateway gateway;
InternationalCheckoutService(@Qualifier("stripeGateway") PaymentGateway gateway) { this.gateway = gateway; }
}
@Configuration // like a Laravel service provider
class HttpClientsConfig {
@Bean
RestClient razorpayClient(@Value("${razorpay.base-url}") String url) {
return RestClient.builder().baseUrl(url).build();
}
}
| Annotation | Effect | Laravel equivalent |
|---|---|---|
| @RestController, @GetMapping | HTTP endpoint | routes/api.php + controller |
| @Service, @Component | Register a bean | Container auto-wiring / bind() |
| @Configuration + @Bean | Manual bean creation | ServiceProvider::register() |
| @Transactional | Wrap method in a DB transaction | DB::transaction() |
| @Valid, @NotNull | Validation | Form Request rules |
| @PreAuthorize | Authorization | Policies / can middleware |
| @Scheduled | Run on a schedule | Console Kernel schedule |
| @Async | Run in another thread | ShouldQueue jobs |
| @KafkaListener | Consume a Kafka topic | Queue worker / Kafka consumer command |
| @Cacheable | Cache method result | Cache::remember() |
5. React: dependency injection with context
React has no container, but Context gives the same benefit: components ask for a service (an API client, the current user) without creating it, and tests or storybooks provide a fake.
Injecting an API client
const ApiContext = createContext(null);
export const useApi = () => useContext(ApiContext);
root.render(<ApiContext.Provider value={createApiClient('/api')}><App /></ApiContext.Provider>);
// test: render(<ApiContext.Provider value={fakeApi}><OrderList /></ApiContext.Provider>)
function OrderList() {
const api = useApi();
const [orders, setOrders] = useState([]);
useEffect(() => { api.get('/orders').then(setOrders); }, [api]);
return orders.map(o => <OrderRow key={o.id} order={o} />);
}
6. Interview questions
register() vs boot()?
register only binds into the container; boot runs after all providers are registered and may use any service.
bind vs singleton?
bind returns a new instance each time; singleton returns the same instance. Do not store request-specific data in singletons under Octane or queue workers.
What is a facade?
A static-looking proxy (Cache::get) that resolves the real object from the container, so it is still swappable in tests (Cache::shouldReceive).
How do annotations work at runtime?
The framework reads them with reflection at startup and builds proxies or registrations; the annotation itself contains no logic.
