Skip to main content
CakeDC Blog

How to Build Reusable Components in CakePHP 5.4

How to Build Reusable Components in CakePHP 5.4

Long-lived CakePHP applications accumulate logic in four places: templates, Tables, controllers, commands. Each addition is reasonable alone; after a few years the result is business logic reachable through one entry point and testable only through the full HTTP stack. The framework has the extraction points — the constraint is deciding where each piece belongs.

This how-to takes one example, publishing an article, from a fat action to a service reused by a controller, a command and a test. Assumes CakePHP 5.4+ on PHP 8.2+. Every snippet below was run against CakePHP 5.4.1.


Step 0: Audit before you refactor

Refactoring without a map produces a differently-shaped mess.

The four smells

1. Logic in templates. <?php if ($user->role === 'admin' && $order->total > 1000): ?> in a .php file makes the approval threshold a presentation detail. When finance changes it, you are grepping templates.

2. Fat Tables. 900 lines of schema, validation, twenty finders, an afterSave sending welcome email, a private method calling an API. The ORM has become the application.

3. Fat controllers. Orchestration, rules and formatting in one action, twelve lines of it copy-pasted into a second controller.

4. Duplicated commands. A command repeating a controller, because the job also runs from cron. The strongest signal of the four: if you copied logic into a command, it was never controller logic.

The extraction map

Smell Extract to Injectable?
Presentation logic in a template Helper No
UI chunk with its own data Cell No
Repeated query logic Custom finder
Concern shared across Tables Behavior No
Rule about one record Entity method No
HTTP logic shared by controllers Component Yes
Cross-cutting request handling Middleware Yes
Workflow spanning models and APIs Service Yes
Optional side effect Event listener Yes
Request payload shaping DTO + #[RequestToDto] Yes
Feature reused across apps Plugin

Why the map looks like this

The table is a summary of a few rules, and the rules are worth more than the table: they place the code the table does not cover. Each one comes from looking at where logic ends up in a CakePHP application after years of shipping, and working out why it landed there.

  • Every framework layer is an adapter. Tables adapt objects to storage, controllers HTTP, commands CLI calls, helpers data to markup. Business logic is the one thing that is not an adapter, so the framework gives it no folder; left homeless it lands in whichever adapter touched it first.
  • One reason to change. A Table that persists articles and emails subscribers changes with the schema and with marketing copy. Two forces, one file.
  • Testability is the measurement. When setup dwarfs the code under test, the code is in the wrong layer — long before the design feels wrong.
  • Depend on what you need, not on where to find it. A locator call means the class knows how to find collaborators; a constructor parameter means it knows only what it requires. That is what the injectable column tells you.
  • Move at the edges. On a live codebase you never get a clean slate. Extract the thing you are already touching for a ticket, leave the rest, and let the next ticket pay for the next piece.

Not every rule earns a class: wrapping get($id) in an ArticleFetchingService is ceremony.


Fixing Tables: where the ORM ends

Query shapes and per-record rules belong here: they change when the data model changes, the same reason the rest of the class does. Anything changing for a different reason does not, regardless of how convenient the callback.

Custom finders

If the same where() chain sits in three controllers, all three know what "published" means in SQL — and nothing tells you which three when the definition moves:

// src/Model/Table/ArticlesTable.php
declare(strict_types=1);

namespace App\Model\Table;

use Cake\I18n\DateTime;
use Cake\ORM\Query\SelectQuery;
use Cake\ORM\Table;

class ArticlesTable extends Table
{
    public function findPublished(SelectQuery $query): SelectQuery
    {
        return $query->where(['Articles.status' => 'published']);
    }

    public function findRecent(SelectQuery $query, int $days = 30): SelectQuery
    {
        return $query
            ->where(['Articles.published_at >=' => new DateTime("-{$days} days")])
            ->orderBy(['Articles.published_at' => 'DESC']);
    }

    public function findScheduled(SelectQuery $query): SelectQuery
    {
        return $query
            ->where([
                'Articles.status' => 'scheduled',
                'Articles.publish_at <=' => new DateTime(),
            ])
            ->orderBy(['Articles.publish_at' => 'ASC']);
    }
}
// Composition is the point — two finders chained into one query, no duplicated conditions.
$articles = $this->fetchTable('Articles')
    ->find('published')
    ->find('recent', days: 7)
    ->all();

Behaviors and entity methods

The scheduling rule belongs on the entity, not in every caller:

// src/Model/Entity/Article.php
public function isDue(): bool
{
    return $this->status === 'scheduled'
        && $this->publish_at !== null
        && $this->publish_at <= new DateTime();
}

Anything repeating identically across Tables — a modified_by stamp on articles, comments, media — is a behavior, not three copies of one beforeSave(). The test: does the second Table need it unchanged? A variation means two rules.

Where the ORM ends

A Table persists and validates. The moment this appears —

public function afterSave(EventInterface $event, EntityInterface $entity, ArrayObject $options): void
{
    if ($entity->isNew()) {
        $mailer = new Mailer('default');
        $mailer->setTo($entity->email)->setSubject('Welcome!')->deliver();

        $this->stripeClient->customers->create(['email' => $entity->email]);
    }
}

— you cannot test it without a mail server and a Stripe account, it fires on every fixture load, and it is invisible: nothing at the call site says "saving this record will charge someone."

Two destinations, and the choice matters more than the move. Part of the operation — the caller is not done until it has run → a service, called explicitly. A side effect the operation should not know about → an event and an injectable listener. What is not on the list is leaving it in afterSave(): a model hook is implicit like an event, mandatory like a service call, and testable like neither.

Making Tables injectable

Register TableContainer as a delegate and Tables become injectable:

// src/Application.php — one line of the services() method shown in full below
use Cake\ORM\Locator\TableContainer;

$container->delegate(new TableContainer());

Type-hint ArticlesTable in a constructor and the container supplies it — no locator calls spreading through the service layer.


Fixing controllers: the service layer

Four concerns, one method, reachable only over HTTP —

class ArticlesController extends AppController
{
    public function publish(int $id): ?Response
    {
        $articles = $this->fetchTable('Articles');
        $article = $articles->get($id);

        if ($article->status === 'published') {
            $this->Flash->error('Article already published');

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

        $article->status = 'published';
        $article->published_at = new DateTime();

        if ($articles->save($article)) {
            $subscribers = $articles->Authors->Subscribers
                ->find()
                ->where(['author_id' => $article->author_id])
                ->all();

            $mailer = new Mailer('default');
            foreach ($subscribers as $subscriber) {
                // reset() clears the transport too, so the profile goes back on.
                $mailer->reset()
                    ->setProfile('default')
                    ->setTo($subscriber->email)
                    ->setSubject('New article published')
                    ->deliver();
            }

            $curl = curl_init();
            curl_setopt($curl, CURLOPT_URL, Configure::read('Search.endpoint'));
            curl_setopt($curl, CURLOPT_POST, true);
            curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($article));
            curl_exec($curl);

            $this->Flash->success('Article published');
        }

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

Four concerns: state transition, notification, indexing, flash messages. Only the last is the controller's job. The test is not "is this method long" but "would it survive changing the entry point?" — whether an article may move to published is a domain rule a POST happens to trigger.

The workflow, extracted

// src/Service/ArticlePublishingService.php
declare(strict_types=1);

namespace App\Service;

use App\Model\Entity\Article;
use App\Model\Table\ArticlesTable;
use Cake\Event\EventDispatcherInterface;
use Cake\Event\EventDispatcherTrait;
use Cake\I18n\DateTime;

class ArticlePublishingService implements EventDispatcherInterface
{
    use EventDispatcherTrait;

    public function __construct(
        private ArticlesTable $articles,
        private EmailService $emails,
        private SearchIndexService $search,
    ) {
    }

    public function publish(Article $article): PublishResult
    {
        if ($article->status === 'published') {
            return PublishResult::AlreadyPublished;
        }

        $article->status = 'published';
        $article->published_at = new DateTime();

        // Everything that must succeed or fail together, and nothing else.
        $saved = $this->articles->getConnection()->transactional(
            fn(): bool => (bool)$this->articles->save($article, ['atomic' => false]),
        );

        if (!$saved) {
            return PublishResult::SaveFailed;
        }

        // Outside the transaction on purpose: an email cannot be rolled back,
        // and an HTTP call must never hold row locks open.
        $this->notifySubscribers($article);
        $this->search->index($article);
        $this->dispatchEvent('Article.published', ['article' => $article]);

        return PublishResult::Published;
    }

    public function schedule(Article $article, DateTime $publishAt): bool
    {
        $article->status = 'scheduled';
        $article->publish_at = $publishAt;

        return (bool)$this->articles->save($article);
    }

    /**
     * @return iterable<\App\Model\Entity\Article>
     */
    public function findDue(int $limit = 50): iterable
    {
        return $this->articles->find('scheduled')
            ->limit($limit)
            ->all();
    }

    private function notifySubscribers(Article $article): void
    {
        $subscribers = $this->articles->Authors->Subscribers
            ->find()
            ->where(['author_id' => $article->author_id])
            ->all();

        foreach ($subscribers as $subscriber) {
            $this->emails->sendNewArticle($subscriber, $article);
        }
    }
}

Two decisions in there are worth more than the extraction itself.

The transaction wraps the writes and nothing else. Everything that must commit or roll back together goes inside; the email, the HTTP call and the event stay outside. Put the mailer inside and a rollback cannot un-send it. Put the search call inside and every slow response holds row locks open for the duration. ['atomic' => false] stops the ORM opening a nested transaction of its own — the outer transactional() already owns the boundary.

The return type carries the reason. A bool collapses "already published" and "the save failed" into one value, and the caller ends up printing the same message for a no-op and a genuine error:

// src/Service/PublishResult.php
declare(strict_types=1);

namespace App\Service;

enum PublishResult
{
    case Published;
    case AlreadyPublished;
    case SaveFailed;
}

Worth knowing which failures that third case covers: save() runs application rules, not validators — validation happens when data is marshalled into an entity, and this workflow mutates the entity directly. So SaveFailed means a buildRules() check said no (or the driver threw). Without rules on the table, the branch is unreachable.

The curl block becomes a service that declares its endpoint:

// src/Service/SearchIndexService.php
declare(strict_types=1);

namespace App\Service;

use App\Model\Entity\Article;
use Cake\Http\Client;

class SearchIndexService
{
    public function __construct(
        private string $endpoint,
        private Client $http,
    ) {
    }

    public function index(Article $article): void
    {
        $this->http->post($this->endpoint, $article->toArray(), ['type' => 'json']);
    }
}

The endpoint is a constructor argument now, so tests pass a fake instead of needing it to exist. Registered as a primitive below, which means config/app.php has to carry the key — Configure::readOrFail() throws while the container is being built, not when the service is first used:

// config/app.php
'Search' => [
    'endpoint' => env('SEARCH_ENDPOINT', 'https://search.example.com/index'),
],

What the controller keeps

// src/Controller/ArticlesController.php
use App\Service\ArticlePublishingService;
use App\Service\PublishResult;

class ArticlesController extends AppController
{
    public function publish(int $id, ArticlePublishingService $publisher): ?Response
    {
        $article = $this->fetchTable('Articles')->get($id);

        match ($publisher->publish($article)) {
            PublishResult::Published => $this->Flash->success('Article published'),
            PublishResult::AlreadyPublished => $this->Flash->error('Already published'),
            PublishResult::SaveFailed => $this->Flash->error('Could not publish article'),
        };

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

What is left is the adapter: read a parameter, call one thing, turn the result into a flash and a redirect. The match is exhaustive, so adding a case to the enum later breaks the controller loudly, instead of silently falling through to a wrong message. The service arrives as an action argument, so the dependency is visible in the signature — controller constructors, components, commands, classname middleware and event listeners resolve the same way.

Scheduling is the other entry point, and where a typed request pays off:

use Cake\Controller\Attribute\RequestToDto;

class ArticlesController extends AppController
{
    public function schedule(
        int $id,
        #[RequestToDto]
        ScheduleArticleDto $dto,
        ArticlePublishingService $publisher,
    ): ?Response {
        $publisher->schedule($this->fetchTable('Articles')->get($id), $dto->publishAt);

        return $this->redirect(['action' => 'view', $id]);
    }
}
// src/Dto/ScheduleArticleDto.php
declare(strict_types=1);

namespace App\Dto;

use Cake\I18n\DateTime;

class ScheduleArticleDto
{
    public function __construct(public DateTime $publishAt)
    {
    }

    public static function createFromArray(array $data): self
    {
        return new self(new DateTime($data['publish_at'] ?? 'now'));
    }
}

The gain is not the attribute — it is that schedule() takes a DateTime rather than array $data.

Wiring

CakePHP 5.4 ships its own container, Cake\Container\Container, selected with a single config key. Everything below goes through the add() / addShared() / addArgument() / delegate() surface of Cake\Core\ContainerInterface:

// config/app.php
'App' => [
    // ...
    'container' => 'cake',
],
// src/Application.php
public function services(ContainerInterface $container): void
{
    // Tables resolve by type hint — no fetchTable() inside services.
    $container->delegate(new TableContainer());

    $container->add('searchEndpoint', Configure::readOrFail('Search.endpoint'));
    $container->add(Client::class);
    // A Mailer built with no profile has no transport; name one.
    $container->add(Mailer::class, fn(): Mailer => new Mailer('default'));

    $container->add(SearchIndexService::class)
        ->addArgument('searchEndpoint')
        ->addArgument(Client::class);

    $container->add(EmailService::class)
        ->addArgument(Mailer::class);

    $container->add(ArticlePublishingService::class)
        ->addArgument(ArticlesTable::class)
        ->addArgument(EmailService::class)
        ->addArgument(SearchIndexService::class);

    // Lock's facade is static; bind the engine so commands stay injectable.
    $container->add(LockInterface::class, fn(): LockInterface => Lock::engine('default'));

    // Commands take the factory as the last argument.
    $container->add(PublishScheduledCommand::class)
        ->addArgument(ArticlePublishingService::class)
        ->addArgument(LockInterface::class)
        ->addArgument(CommandFactoryInterface::class);
}

Five things bite in production rather than in tests: services resolve fresh unless registered with addShared(); anything a ServiceProvider omits from provides() stays invisible; components need ComponentRegistry as their first argument; an addArgument('SomeId') that names nothing is not an error at registration or at resolution — the container hands the constructor the string itself, and what you see is a TypeError from the constructor, one step away from the cause; and new Mailer() with no profile has no transport, so it throws BadMethodCallException the first time something sends. That last one has a second edge to it — Mailer::reset() sets the transport back to null, so a shared, injected Mailer needs setProfile() again before every message. The DI chapter covers the rest.

The optional side effect

Statistics are not part of publishing — nothing breaks if they are removed — which makes them an event, not a fourth constructor argument. Listeners returned from eventListeners() are built by the container, so they take dependencies too:

// src/Event/ArticleStatisticsListener.php
declare(strict_types=1);

namespace App\Event;

use App\Service\StatisticsClient;
use Cake\Event\EventInterface;
use Cake\Event\EventListenerInterface;

class ArticleStatisticsListener implements EventListenerInterface
{
    public function __construct(
        private StatisticsClient $statistics,
    ) {
    }

    public function implementedEvents(): array
    {
        return [
            // Run last, so a stopped or failed workflow is not counted.
            'Article.published' => ['callable' => 'record', 'priority' => 100],
        ];
    }

    public function record(EventInterface $event): void
    {
        $this->statistics->increment('articles.published', $event->getData('article')->author_id);
    }
}
// src/Application.php
public function eventListeners(): array
{
    return [ArticleStatisticsListener::class];
}

// ...and in services(), alongside the registrations above:
$container->addShared(StatisticsClient::class);
$container->addShared(ArticleStatisticsListener::class)
    ->addArgument(StatisticsClient::class);

Two costs. eventListeners() attaches to the global manager, so a broadly-named event fires for every subject that emits it — check $event->getSubject(). And an event is invisible at the call site: nothing in publish() tells us that anything follows. Worth it only for concerns the workflow should not know about.


Fixing commands: thin commands over shared services

A command is an adapter too, differing only in what it translates — arguments instead of a request, exit codes instead of a redirect:

// src/Command/PublishScheduledCommand.php
declare(strict_types=1);

namespace App\Command;

use App\Service\ArticlePublishingService;
use App\Service\PublishResult;
use Cake\Command\Command;
use Cake\Console\Arguments;
use Cake\Console\CommandFactoryInterface;
use Cake\Console\ConsoleIo;
use Cake\Console\ConsoleOptionParser;
use Cake\Lock\LockInterface;

class PublishScheduledCommand extends Command
{
    public function __construct(
        private ArticlePublishingService $publisher,
        private LockInterface $locks,
        ?CommandFactoryInterface $factory = null,
    ) {
        parent::__construct($factory);
    }

    protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser
    {
        return $parser
            ->setDescription('Publish articles whose scheduled time has passed.')
            ->addOption('limit', ['default' => 50, 'help' => 'Max articles per run']);
    }

    public function execute(Arguments $args, ConsoleIo $io): int
    {
        // Cron does not wait for the previous run to finish. This does.
        $lock = $this->locks->acquire('articles.publish-scheduled', ttl: 300);

        if ($lock === null) {
            $this->io->warning('A previous run is still going; exiting.');

            return static::CODE_SUCCESS;
        }

        try {
            $published = 0;

            foreach ($this->publisher->findDue((int)$this->args->getOption('limit')) as $article) {
                $result = $this->publisher->publish($article);

                if ($result === PublishResult::Published) {
                    $published++;
                    $this->io->verbose("Published: {$article->title}");
                } else {
                    $this->io->warning("Skipped {$article->title}: {$result->name}");
                }
            }

            $this->io->success("Published {$published} article(s).");
        } finally {
            $lock->release();
        }

        return static::CODE_SUCCESS;
    }
}

The base class is Cake\Command\Command; the 4.x Cake\Console\Command is gone, while Arguments, ConsoleIo, ConsoleOptionParser and CommandFactoryInterface all stayed in Cake\Console. $this->io and $this->args remove the need to thread execute()'s parameters through helper methods — which is what pushes people into one long execute(). What is left is parsing, output and an exit code, and cron, HTTP and a queue job now run the same path.

The lock is the part people skip. Cron fires on a schedule, not on completion: if a run takes longer than its interval the next one starts anyway, findDue() hands the same rows to both processes, and subscribers get two emails.

acquire() returns an AcquiredLock, or null when someone else holds the resource — there is no exception to catch, so a second process just exits. The lock carries an ownership token, so releasing only ever releases your lock, never one a parallel run acquired after yours expired. Release in finally: the object does attempt a release on destruction, but relying on that means a fatal error can leave the resource held until the TTL runs out.

Configure an engine once in config/bootstrap.php:

use Cake\Lock\Engine\RedisLockEngine;
use Cake\Lock\Lock;

Lock::setConfig('default', [
    'className' => RedisLockEngine::class,
    'host' => '127.0.0.1',
    'port' => 6379,
    'ttl' => 300,
]);

FileLockEngine, MemcachedLockEngine and NullLockEngine ship alongside it; a file lock is enough while the cron runs on one host, and its path defaults to sys_get_temp_dir() . '/cake_locks' unless you set one. The facade itself is static; the LockInterface binding in the wiring file above is what keeps the command injectable and testable.

Two things worth knowing. Lock::synchronized($resource, $callback) wraps acquire-run-release for you and is the better shape when it fits — but it blocks for up to ten seconds waiting, which is the opposite of what a cron guard wants. And for a job that may outlive its TTL, call $lock->refresh() inside the loop rather than picking a TTL large enough to cover the worst case; a huge TTL means a crashed run blocks the next one for that long.


Testing: the reason any of this pays off

Constructing your own collaborators is what makes code untestable — not length, not complexity. The original action built its own mailer and HTTP call, so it only ran with both available. Three tests now exist that could not have been:

// 1. Through the HTTP layer, with the mail service replaced.
//    Fixture: article 3 is scheduled and its author has two subscribers.
public function testPublishSendsNotifications(): void
{
    $emails = $this->createMock(EmailService::class);
    $emails->expects($this->exactly(2))->method('sendNewArticle');
    $this->mockService(EmailService::class, fn() => $emails);
    $this->mockService(SearchIndexService::class, fn() => $this->createStub(SearchIndexService::class));

    // The skeleton ships CsrfProtectionMiddleware switched on.
    $this->enableCsrfToken();
    $this->enableSecurityToken();
    $this->post('/articles/publish/3');

    $this->assertRedirect(['controller' => 'Articles', 'action' => 'view', 3]);
    $this->assertFlashMessage('Article published');
}

// 2. Through the console, same service, same mock API.
public function testCommandPublishesDueArticles(): void
{
    $this->mockService(EmailService::class, fn() => $this->createStub(EmailService::class));
    $this->mockService(SearchIndexService::class, fn() => $this->createStub(SearchIndexService::class));

    $this->exec('publish_scheduled --limit=10');

    $this->assertExitSuccess();
    $this->assertOutputContains('Published 1 article(s).');
}

// 3. No HTTP, no console, no fixtures for the API call.
public function testAlreadyPublishedArticleIsSkipped(): void
{
    $emails = $this->createMock(EmailService::class);
    $search = $this->createMock(SearchIndexService::class);
    $emails->expects($this->never())->method('sendNewArticle');
    $search->expects($this->never())->method('index');

    $service = new ArticlePublishingService($this->fetchTable('Articles'), $emails, $search);
    $article = $this->fetchTable('Articles')->newEntity(['status' => 'published']);

    $this->assertSame(PublishResult::AlreadyPublished, $service->publish($article));
}

The first two need Cake\TestSuite\IntegrationTestTrait and Cake\Console\TestSuite\ConsoleIntegrationTestTrait. mockService() registrations clear after each test, and both tests replace every collaborator that would leave the process: a real EmailService in a test means a live SMTP server. Doubles with no expectations on them are stubs, not mocks — from PHPUnit 13, createMock() without an expects() emits a notice per test. Note what the enum bought in the third: the assertion distinguishes a no-op from a failure, which a bool return could not. The third is the interesting one — no framework, no fixtures, milliseconds — and it exists because publish() receives what it needs instead of finding it.


When to make it a plugin

Not yet, for most code. Extract when a second application uses the feature, or when it carries its own migrations, config and templates. Below that, src/ is fine — a plugin adds a release burden a folder does not. Plugins have their own services() and eventListeners() hooks, so they ship their wiring.


Pitfalls

  • God services. A 1,500-line OrderService is the fat controller with a new namespace.
  • The container as a locator. Injecting the container and calling get() inside a class hides every dependency it has.
  • Careless addShared(). Singletons holding request state work until two requests overlap.
  • Interfaces for everything. One implementation and no test seam is a file, not a design.
  • No characterization tests. Pin current behaviour first, even where it is odd.

Where to start on Monday

  1. Pick one action everyone avoids.
  2. Write a characterization test for what it does today.
  3. Name each concern in it — HTTP, business rule, formatting, external call.
  4. Look each up in the extraction map.
  5. Move one. Run the test.
  6. Inject the result instead of constructing it.

Then ship. This is a habit, not a project.

Summary

Reusable components in CakePHP are less a matter of learning APIs than of asking, each time you add code, which layer it belongs to. The framework's layers are adapters. Tables adapt objects to storage, controllers adapt HTTP, commands adapt CLI invocations, helpers adapt data to markup, and each should hold only the translation it exists for. Business logic is the one concern that adapts nothing, which is why it has no folder of its own and why it drifts into whichever adapter touched it first. Creating a place for it is the whole job.

The refactor in this article followed one action through that reasoning. The state transition, subscriber notification and search indexing left the controller for ArticlePublishingService, which the container builds and hands to an action, a console command and a test alike. The curl block became SearchIndexService, taking its endpoint and HTTP client as constructor arguments rather than reaching for them. Query definitions moved to finders on ArticlesTable; the scheduling rule moved onto the Article entity. Statistics — a concern the workflow should not know about — became an event and an injectable listener registered through eventListeners(). What stayed in the controller is a few lines that are genuinely about HTTP.

Three signals tell you a piece of code is in the wrong place, and they are worth more than any diagram. If the same logic exists in a controller and a command, it was never controller logic. If a class changes for two unrelated reasons, a schema migration and a marketing rewrite, it is holding two responsibilities. Also, if a test needs more scaffolding than the code it covers, the code sits in the wrong layer, that signal fires long before the design starts to feel wrong.

Three details in that refactor matter more than the file moves. The transaction wraps the writes and stops there, so a rollback never has to un-send an email and a slow HTTP call never holds row locks. The service returns an enum rather than a bool, so callers can tell a no-op from a failure instead of printing one message for both. And the cron command takes a named lock, because cron fires on a schedule rather than on completion, and two overlapping runs will happily process the same rows twice.

None of this argues for extracting everything. CRUD belongs in Table classes, record-level rules on entities, formatting in helpers, validation in Table rules. A service that wraps a single get() is ceremony, an event that fires a required step hides control flow, and a fifteen-hundred-line service is the fat controller with a new namespace. The gain comes from a small number of correct placements, not from a large number of new files, so pick one action, pin its behaviour with a test, move a single concern, and let the next one wait until the first has proven itself.

If this sounds like your codebase: a CakePHP application that has become hard to maintain, new features slowed by technical debt accumulated over the years, business logic mixed into controllers or scattered across layers where every change is a risk, CakeDC, the company behind CakePHP, does exactly this kind of work. Get in touch and let's talk about where your project is and how to move it forward.

Further reading

Back to all articles
We Bake with CakePHP