Skip to main content

Dependency Injection

FrankPHP 1.2.0 introduces a dependency injection container. This is an architectural change worth understanding, because it touches how services are built, how controllers receive them, and how you extend both as your application grows. It is also worth understanding why the approach is what it is — explicit and readable — rather than following the fully-automated style that larger frameworks use.

Why this was added

Before 1.2.0, services were constructed inside the methods that used them.
This works when services have no dependencies of their own. It stops working the moment a service needs something handed to it — like SMTP credentials, a database connection, or another service. When EmailService was updated in 1.1.0 to read credentials from .env via config.php, its constructor changed from requiring nothing to requiring seven parameters. Every place in the codebase that wrote new EmailService() broke immediately. There were three of them. That is the core problem. When you construct services at the call site, every constructor change ripples outward through every caller. The more services a service depends on, the worse this gets. A dependency injection container solves this cleanly: services are constructed once, in one place, with all their dependencies explicitly provided. The rest of the application just asks for what it needs.

What the container actually is

The FrankPHP container is a single file: framework/Core/Container.php. It is around sixty lines of plain PHP. It has five methods. There is no reflection, no attribute scanning, no class map generation, no compiled cache. You can read the entire file in two minutes and understand exactly how it works.
That is the entire API for the common case.
singleton() and bind() now throw if the id you’re registering is already bound — one framework component can no longer silently shadow another. override(id, factory, shared = true) is the one deliberate way to replace an existing binding on purpose. See “Deliberately replacing a binding” below.

How it is different from Laravel or Symfony

In Laravel and Symfony, the container inspects your constructor type hints using PHP reflection and automatically resolves and injects dependencies. You declare what you need, and the framework figures out how to build it. That approach is convenient. It is also opaque. When something goes wrong, you have to understand a resolution chain that runs behind the scenes. When an AI agent is helping you build features, it has to infer the binding behaviour rather than read it. FrankPHP takes a different position. Every binding is a plain PHP closure written explicitly in bootstrap.php. Nothing is inferred. Nothing is implicit. PasswordResetService is framework-owned, so its binding lives in framework/bootstrap.php — if you want to know what it’s built with, that’s where you look:
This is not a shortcut. This is the same explicit code you would have written inside the controller method anyway — just moved to the one place where it belongs. It’s also not a file you should edit as part of application feature work — see AI Context Briefing on the framework/application ownership boundary. Your own application bindings follow the identical pattern, just registered in app/bootstrap.php instead — see “Extending the container for your own application” below. The result is a framework that now has a proper service wiring layer, aligned with how every major PHP framework handles this problem, while remaining completely transparent about what it is doing.

How the container fits into the request lifecycle

The container is created inside framework/bootstrap.php, after config is loaded, and is handed to the Router. framework/bootstrap.php registers every framework-owned binding, then hard-requires app/bootstrap.php as its final step — which is where your own application bindings are registered, using the same $container and $router already in scope. When a request comes in, the Router checks whether the matched controller has a binding in the container. If it does, the container builds it. If it does not, the Router falls back to new ControllerName() as before.
Controllers that have no constructor dependencies — which is most of them — work exactly as before. You only touch the container when a controller genuinely needs something injected.

The framework services

Six services are registered in the container by framework/bootstrap.php and should not need to be modified as part of application feature work. EmailService is registered as a singleton. It is built once from $config['mail'] and is the only place in the application that knows about SMTP credentials. Every service that needs to send email receives this single instance. PasswordResetService is registered as a singleton. It depends on EmailService and the User model. SignupService is registered as a singleton. It depends on EmailService and the User model. UserService is registered as a singleton. It depends on the User model — owns business logic for the user record (recordLogin(), saveSettings()). TenantService is registered as a singleton. It depends on the Tenant model — owns business logic for the tenant record (saveSettings()). UserManagementService is registered as a singleton. It depends on the User model — provides the data payload for the admin/owner user management dashboard.
EmailService, PasswordResetService, SignupService, UserService, TenantService, and UserManagementService are framework-owned. They are documented in codebase.md §8.2 and §16.5. Do not modify these bindings as part of application feature work — if you need to change framework behaviour, that is a deliberate upgrade decision.

Deliberately replacing a binding

Because singleton() and bind() now throw on a duplicate id, you can’t accidentally shadow a framework binding by registering the same class twice. If you genuinely need to replace one — say, swapping the framework’s EmailService wiring for a variant with extra logging — use override() from app/bootstrap.php:
This is a deliberate, visible replacement rather than something that can happen by accident. Router::override(method, pattern, handler, middleware) follows the identical pattern for replacing a framework-registered route.

Extending the container for your own application

When you build a new feature that involves a service with dependencies, you register it in app/bootstrap.php. That is the only file that changes.

When you need to extend the container

You need to add a container binding when a service you are building depends on:
  • EmailService — because it needs the SMTP credentials
  • The database PDO instance — when you want to be explicit about it rather than relying on Database::getPdo() inside the service
  • Another service you have already registered
  • Configuration values from $config
You do not need a container binding for services that construct themselves fine with new ServiceName(). If your service takes no dependencies, there is nothing to inject. You do not need a container binding for models. Models resolve their own database connection in their constructor via BaseModel. Use new Model() inside your service constructor.

Step 1: Register your service

Add a singleton() call to the container block in app/bootstrap.php. Place it after the existing application service registrations.
The factory closure receives the container itself as $c. Use $c->make(ClassName::class) to pull in any already-registered service your new service depends on — note that InvoiceService is your own (App\...), but EmailService and Database are framework-owned (Frank\...) regardless of which side is doing the depending.

Step 2: Register your controller binding (if needed)

If the controller for this feature takes your service as a constructor argument, register a bind() for it. Use bind() for controllers rather than singleton(). A fresh controller instance per request is the correct behaviour — controllers can carry request-specific state.

Step 3: Declare the dependency in your controller constructor

Step 4: Register your route

Nothing about the route definition changes. The container is invisible to the routing layer.
The Router will find the controller binding you registered and build it with your service already in place.

A complete worked example

Here is the full pattern for a new feature — an InvoiceService that sends emails and writes to the database. app/Services/InvoiceService.php
In app/bootstrap.php — added after the framework singletons have already been registered by framework/bootstrap.php:
Result: EmailService is built once from .env, by the framework. InvoiceService receives it. InvoiceController receives InvoiceService. The controller method never calls new on anything.

Sending a new type of email

If your service needs to send an email type that does not already exist on EmailService, the right place to add it is your own application-owned service — not EmailService itself. EmailService is framework-owned; adding application-specific send*() methods to it means they’re silently lost on the next framework update. Instead, write an application service (for example App\Services\NotificationService) that constructs its own EmailMessage and calls the injected EmailService::send() directly:
Your controller calls $this->notificationService->sendInvoice(...) and receives an EmailResult. It never sees credentials, SMTP configuration, or PHPMailer.
This is the rule: EmailService owns how emails are sent. Your own application services own what gets sent and when. Call sites pass business data only — never SMTP configuration.

The singleton vs bind distinction

singleton() — the factory is called once. Every make() call after the first returns the same instance. Use singleton for services. They are stateless and safe to share. Building them once is also more efficient when multiple controllers or services depend on the same thing. bind() — the factory is called fresh on every make(). Use bind for controllers. A controller can accumulate request-specific state, so a fresh instance per request is safer. In practice the difference is academic for most controllers, but it is the correct default. Both now throw if you try to register the same id twice — use override() if replacing an existing binding is genuinely what you mean to do.

What you do not need to register

Models. Construct them with new ModelName() inside your service constructor. BaseModel resolves its database connection automatically. Middleware. The Router constructs middleware directly. It has no constructor dependencies. Controllers with no constructor arguments. The Router falls back to new ControllerName() for any controller that has no container binding. If your controller does not need anything injected, you do not need to register it. Request and Response. These are per-request value objects, not services.

Updating MYAPP.md

Every container binding you add for application code is an application-owned decision. When you register a new service or controller binding, update MYAPP.md at the same time. In the Services section of MYAPP.md, record:
  • the service class name
  • what it depends on
  • whether it is registered as singleton or bind
  • a brief description of what it does
This keeps the AI context file accurate, which in turn means future AI sessions can work with the container correctly without guessing.

Conclusion

The DI container brings FrankPHP into architectural alignment with how every major PHP framework handles service construction — without giving up what makes FrankPHP useful for explicit, agentic development. There is no scanning, no reflection, no magic resolution. Every binding is a readable PHP closure in one file. You can trace any service back to exactly where it is built and exactly what it was given. When something breaks, you know where to look. The container is also designed to stay out of the way. Most controllers in a FrankPHP application have no constructor dependencies and require no registration at all. You only engage the container when you are building something that genuinely needs it. That is the FrankPHP approach: bring in the right architectural patterns, but only as much of them as is actually useful.