Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
CakePHPDevelopmentPHPArchitecture guide

Dependency injection in CakePHP 5: services, providers and testing

How the CakePHP 5 service container works: where CakePHP injects services, when a service is worth it, shared services, configuration injection, service providers, the optional 5.4 built-in container, and swapping services in tests.

Cloudsoft Zone

Updated 8 min read

Close-up of a monitor displaying HTML and PHP source code in a text editor
Photograph: Markus Spiske from Forchheim, Bavaria Upper Franconia / CC0, via Wikimedia Commons
In this article

Controllers in older CakePHP applications tend to grow. They load tables, call external APIs, send email, write files and log, all in the same action. That code works, but it is hard to test without sending real email or calling a real payment gateway, and hard to reuse from a console command or a queue job.

CakePHP 5 includes a PSR-11 service container that fixes this without a separate framework. You define services in Application::services(), and CakePHP injects them into controller constructors, controller actions, components, commands and middleware. This article explains how the container works in CakePHP 5.4, when it is worth using, how to organise services with providers, and how to replace services with fakes in tests.

Everything here follows the Dependency Injection chapter of the CakePHP 5.x book, checked on 7 October 2026.

Where CakePHP uses the container

The container is not only for your own services. According to the book, CakePHP uses it when:

  • constructing controllers;
  • calling controller actions (so actions can take service parameters);
  • constructing components;
  • constructing console commands;
  • constructing middleware added by class name.

Your services can also depend on the current ServerRequest in controller actions, because CakePHP adds it to the container automatically.

Diagram showing a repository injected into a manager, which is injected into a controller
Dependencies flow inward: each class receives what it needs instead of creating it. Diagram: Frap, CC0.

When a service is worth it, and when it is not

The book is direct about this: dependency injection is not always necessary. Simple CRUD belongs in table classes used from controllers; entity-specific logic belongs on entities; formatting belongs in helpers; validation belongs in table validation rules. Reach for a service when you have:

  • a business workflow spanning several models (placing an order, onboarding a customer);
  • an external API integration (payments, email providers, storage);
  • logic reused by several controllers, commands or jobs;
  • code that needs extensive unit testing in isolation.

A useful rule: if you would have to mock something to test the controller action, that something probably belongs in a service.

Defining and injecting a service

Start with a plain class. Here an invoice service depends on an interface for a payment gateway, so that the real gateway can be swapped for a fake:

PHP
<?php
declare(strict_types=1);

namespace App\Service;

interface PaymentGatewayInterface
{
    public function charge(int $amountInCents, string $currency, string $reference): string;
}
PHP
<?php
declare(strict_types=1);

namespace App\Service;

use App\Model\Table\InvoicesTable;

class InvoiceService
{
    public function __construct(
        private PaymentGatewayInterface $gateway,
        private InvoicesTable $invoices,
    ) {
    }

    public function pay(int $invoiceId): void
    {
        $invoice = $this->invoices->get($invoiceId);
        $chargeId = $this->gateway->charge($invoice->total_cents, $invoice->currency, 'INV-' . $invoice->id);

        $invoice->charge_id = $chargeId;
        $invoice->status = 'paid';
        $this->invoices->saveOrFail($invoice);
    }
}

Register both in src/Application.php. The skeleton already contains an empty services() method with a commented-out line for table injection:

PHP
use App\Service\InvoiceService;
use App\Service\PaymentGatewayInterface;
use App\Service\StripePaymentGateway;
use Cake\Core\ContainerInterface;
use Cake\ORM\Locator\TableContainer;

public function services(ContainerInterface $container): void
{
    // Let the container resolve ORM table classes (added in 5.3).
    $container->delegate(new TableContainer());

    $container->add(PaymentGatewayInterface::class, StripePaymentGateway::class);
    $container->add(InvoiceService::class)
        ->addArgument(PaymentGatewayInterface::class)
        ->addArgument(\App\Model\Table\InvoicesTable::class);
}

Now any controller action can ask for it by type:

PHP
// src/Controller/InvoicesController.php
public function pay(InvoiceService $invoices, string $id)
{
    $this->request->allowMethod(['post']);
    $invoices->pay((int)$id);
    $this->Flash->success('Payment received.');

    return $this->redirect(['action' => 'view', $id]);
}

The service is created by the container on demand. Route parameters such as $id are still passed to the action as before.

Shared or not?

Services are not shared by default: every fetch creates a new instance along with its dependencies. Use addShared() when one instance should be reused, for example an API client that holds a connection pool or a configuration object:

PHP
$container->addShared(StripePaymentGateway::class);

Configuration values

Never hard-code keys in service definitions. Since CakePHP 5.3 you can inject a single configuration value with the #[Configure] attribute, or inject a read-only ServiceConfig object that exposes all configuration:

PHP
use Cake\Core\Attribute\Configure;

class StripePaymentGateway implements PaymentGatewayInterface
{
    public function __construct(
        #[Configure('Stripe.secretKey')] private string $secretKey,
    ) {
    }

    // charge() implementation ...
}

Keep the actual secret in an environment variable that config/app_local.php reads, so it never lands in version control.

Constructor injection in commands and components

Commands and components are built by the container too, but you define them as services with their arguments. For a command, the book's pattern is to add the command itself and pass the service plus the command factory:

PHP
// src/Command/ChargeOverdueCommand.php
use Cake\Command\Command;
use Cake\Console\Arguments;
use Cake\Console\CommandFactoryInterface;
use Cake\Console\ConsoleIo;

class ChargeOverdueCommand extends Command
{
    public function __construct(
        protected InvoiceService $invoices,
        ?CommandFactoryInterface $factory = null,
    ) {
        parent::__construct($factory);
    }

    public function execute(Arguments $args, ConsoleIo $io): int
    {
        // ... find overdue invoices and call $this->invoices->pay($id)
        return static::CODE_SUCCESS;
    }
}

// src/Application.php, inside services()
$container->add(ChargeOverdueCommand::class)
    ->addArgument(InvoiceService::class)
    ->addArgument(CommandFactoryInterface::class);

Now the same payment logic runs from the web and from cron without duplication. Components follow the same shape: the constructor takes the ComponentRegistry, your services, and the config array, and you register the component with matching arguments.

Code editor window showing PHP source code with syntax highlighting on a dark background
Constructor injection keeps dependencies visible in the class signature. Photo: Yuri Samoilov, CC BY 2.0.

Organising services with providers

As an application grows, services() becomes long. Service providers group related definitions, and they are registered lazily: their services are only defined when one of them is first requested. Every service a provider offers must be listed in $provides, or it cannot be loaded from the container.

PHP
<?php
declare(strict_types=1);

namespace App\ServiceProvider;

use App\Service\InvoiceService;
use App\Service\PaymentGatewayInterface;
use App\Service\StripePaymentGateway;
use Cake\Core\ContainerInterface;
use Cake\Core\ServiceProvider;

class BillingServiceProvider extends ServiceProvider
{
    protected array $provides = [
        PaymentGatewayInterface::class,
        InvoiceService::class,
    ];

    public function services(ContainerInterface $container): void
    {
        $container->addShared(PaymentGatewayInterface::class, StripePaymentGateway::class);
        $container->add(InvoiceService::class)
            ->addArgument(PaymentGatewayInterface::class)
            ->addArgument(\App\Model\Table\InvoicesTable::class);
    }
}

// src/Application.php, inside services()
$container->addServiceProvider(new BillingServiceProvider());

A provider can also implement bootstrap() to load extra configuration or register further providers when it is added.

Auto-wiring: league/container or the new built-in container

CakePHP 5 uses league/container by default, and with it auto-wiring is off: the container only builds what you defined. You can turn auto-wiring on by delegating to a ReflectionContainer; pass true to cache resolutions, because uncached reflection costs performance.

CakePHP 5.4 adds an alternative: its own PSR-11 container in Cake\Container, enabled by setting App.container to cake in config/app.php. It auto-wires concrete classes and caches those resolutions by default, so in the example above only the interface mapping would be required. Your services() method keeps type-hinting Cake\Core\ContainerInterface, and add(), addShared(), extend(), tags and delegates work with both. Do not add a ReflectionContainer when using the built-in one.

Questionleague/container (default)Built-in container (5.4, opt-in)
How to enableNothing to do'App' => ['container' => 'cake']
Auto-wiringOff unless you delegate to ReflectionContainerOn, with cached resolutions
Explicit definitions still needed forEvery serviceInterfaces, scalars, arrays, shared services, custom arguments
Service providers, tags, extend()YesYes

Explicit definitions remain good practice for anything security-sensitive or expensive, because they document intent and control sharing.

Replacing services in tests

This is where the design pays off. In tests that use IntegrationTestTrait or ConsoleIntegrationTestTrait, mockService() swaps a container entry for the duration of the test; mocks are cleaned up automatically afterwards.

PHP
<?php
declare(strict_types=1);

namespace App\Test\TestCase\Controller;

use App\Service\PaymentGatewayInterface;
use Cake\TestSuite\IntegrationTestTrait;
use Cake\TestSuite\TestCase;

class InvoicesControllerTest extends TestCase
{
    use IntegrationTestTrait;

    protected array $fixtures = ['app.Invoices'];

    public function testPayUsesGateway(): void
    {
        $this->mockService(PaymentGatewayInterface::class, function () {
            return new class implements PaymentGatewayInterface {
                public function charge(int $amountInCents, string $currency, string $reference): string
                {
                    return 'test_charge_1';
                }
            };
        });

        $this->enableCsrfToken();
        $this->post('/invoices/pay/1');

        $this->assertRedirect(['action' => 'view', '1']);
    }
}

No network call is made, and the test asserts the behaviour you care about. CakePHP 5.4 also adds TestCase::mockModel() for Mockery-based model mocks when you need them.

Common mistakes to avoid

  • Using the container as a service locator. Fetching services from the container inside business code hides dependencies again. Ask for them in constructors or action parameters instead, so the signature tells readers and tests what a class needs.
  • Forgetting $provides. A service defined in a provider but missing from its $provides array cannot be loaded, and the resulting error can look like a missing definition.
  • Sharing stateful services by accident. addShared() is right for stateless clients and configuration, but a shared object that stores per-request data can leak state between operations in long-running workers.
  • Turning every table call into a service. The book explicitly recommends using table classes directly for simple CRUD. A service that only forwards to find() and save() adds indirection without benefit.
  • Uncached auto-wiring in production. If you enable ReflectionContainer with the default league/container setup, pass true so resolutions are cached; the book warns that uncached reflection can hurt performance.
  • Secrets in definitions. Keys passed as literal strings to add() end up in version control. Read them from configuration that comes from the environment.

A sensible migration path for an existing application is incremental. Pick one controller action that talks to an external system, extract that code into a service with an interface, register it, inject it into the action, and write one integration test that mocks it. Repeat for the next integration. Within a few iterations the controllers become thin and the riskiest code is covered by fast tests.

Summary

Use the CakePHP container for workflows, integrations and logic shared across entry points, and leave plain CRUD in tables and controllers. Depend on interfaces for anything external, register services in services() or in lazily loaded providers, inject configuration with #[Configure], and swap implementations in tests with mockService(). If you are on 5.4, try the built-in container on a branch: it removes most boilerplate definitions while keeping the same API.

Frequently asked questions

  • Where can CakePHP inject services?

    Into controller constructors and actions, components, console commands, and middleware that is added by class name, according to the CakePHP 5 book.

  • Are CakePHP container services singletons?

    No. Services are not shared by default; each fetch builds a new instance. Use addShared() for a single reused instance.

  • Can I inject ORM tables into services?

    Yes. Since CakePHP 5.3, delegate to Cake\ORM\Locator\TableContainer in services() and table classes can be resolved by the container.

  • Does CakePHP auto-wire dependencies?

    Not with the default league/container setup unless you delegate to a ReflectionContainer. The built-in container added in 5.4, enabled with App.container set to cake, auto-wires concrete classes by default.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. Dependency Injection — CakePHP Book (5.x)
  2. 5.4 Migration Guide — CakePHP Book (5.x)
  3. 5.3 Migration Guide — CakePHP Book (5.x)
  4. cakephp/app skeleton Application.php (5.x branch) — CakePHP on GitHub

Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.

Comments

No comments yet. Be the first to join the conversation.

Leave a comment

Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.