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.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.
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 inbootstrap.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:
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 insideframework/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.
The framework services
Six services are registered in the container byframework/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
Becausesingleton() 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:
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 inapp/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
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 asingleton() call to the container block in app/bootstrap.php. Place it after the existing application service registrations.
$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 abind() 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.A complete worked example
Here is the full pattern for a new feature — anInvoiceService that sends emails and writes to the database.
app/Services/InvoiceService.php
app/bootstrap.php — added after the framework singletons have already been registered by framework/bootstrap.php:
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 onEmailService, 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:
$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 withnew 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
singletonorbind - a brief description of what it does