dw darren / blog v2026.8
Back to blog
GET /v1/posts/cakephp-and-laravel-shared-php-mvc-lineage-not-derivation 200 OK · 9 min read

CakePHP and Laravel Don't Share a Codebase, But They Share a Lineage

CakePHP and Laravel logos side by side

I keep seeing some version of "Laravel is built on CakePHP" repeated as fact. It isn't. Taylor Otwell wrote Laravel from scratch in 2011, and its first commit shares no code, no author, and no direct inheritance with CakePHP, which had already been out for six years at that point. What the two frameworks actually share is a common ancestor: the PHP MVC conventions that CakePHP itself popularised in the mid-2000s, borrowed in turn from Ruby on Rails.

I spent a few days playing around with a CakePHP 5 SaaS project as a deliberate exercise, on purpose, as a Laravel developer, to see how much of that shared ancestry still shows up in practice. The answer is: a lot at the surface, and almost none of it underneath. This post covers where the frameworks actually came from, then walks through concrete code from that project showing where the concepts map cleanly and where they don't map at all.

Where each framework actually came from

CakePHP started in 2005 as an explicit, self-described Rails port for PHP. Its early documentation used Rails terminology directly: convention over configuration, scaffolding, ActiveRecord-style models. It was one of the first PHP frameworks to make "put your controller in this folder, name your model this way, and the framework will find it" a workable pattern in a language that, at the time, mostly meant a folder of loose scripts with database calls scattered through the templates.

Laravel arrived in 2011 into a very different landscape. CodeIgniter was the dominant lightweight PHP framework, and it had no built-in authentication, no expressive routing beyond basic pattern matching, and nothing resembling an ORM. Taylor Otwell built Laravel specifically to fix those gaps in CodeIgniter, not to extend or fork CakePHP. Laravel's own early history documents CodeIgniter as the direct point of comparison and frustration, with Symfony components (routing, HTTP foundation) adopted underneath for the pieces that needed to be solid rather than reinvented.

So the actual family tree looks like this:

  • Ruby on Rails (2004) popularised convention-over-configuration MVC for the web
  • CakePHP (2005) brought that pattern to PHP, directly and explicitly modelled on Rails
  • CodeIgniter (2006) took a lighter, less opinionated MVC approach in parallel, no Rails influence claimed
  • Laravel (2011) was written to replace CodeIgniter, using Symfony components for its foundation and building its own conventions on top, independent of CakePHP's codebase

CakePHP and Laravel are cousins by convention, not parent and child by code. That distinction matters because it explains why so many concepts translate cleanly between them (both drew on the same Rails-era ideas about what a web framework should provide) while the actual implementations diverge completely once you look past the naming.

Where the conventions genuinely rhyme

The shared Rails ancestry shows up most clearly in the shape of a request. Both frameworks route a URL to a controller action, both expect a `Model` and a `Table`/`Eloquent model` layer sitting between the controller and the database, and both ship a templating layer that expects a layout wrapping individual views. None of that is coincidence: it's the same MVC skeleton Rails established, inherited by CakePHP directly and by Laravel indirectly through the same design conversation the rest of the web framework world was having in the mid-2000s.

Multi-tenancy is a good example of a concept that translates almost word for word. In Laravel, you'd typically scope every tenant-owned model with a global scope:

class Booking extends Model
{
    protected static function booted(): void
    {
        static::addGlobalScope('tenant', function (Builder $builder) {
            $builder->where('business_id', app('currentTenant')->id);
        });
    }
}

CakePHP's Table layer solves the identical problem with a Behavior instead of a scope, hooked into the same lifecycle event (find, not the model's constructor):

class TenantScopeBehavior extends Behavior
{
    protected ?int $tenantId = null;

    public function setTenantId(?int $tenantId): void
    {
        $this->tenantId = $tenantId;
    }

    public function beforeFind(EventInterface $event, SelectQuery $query, ArrayObject $options, bool $primary): void
    {
        if (!empty($options['unscoped'])) {
            return;
        }

        if ($this->tenantId === null) {
            throw new RuntimeException(sprintf(
                'TenantScopeBehavior on %s requires setTenantId() to be called before querying.',
                $this->table()->getAlias(),
            ));
        }

        $query->andWhere([$this->table()->aliasField('business_id') => $this->tenantId]);
    }
}

Same idea, same event-driven mechanism, entirely different vocabulary and a genuinely different design decision worth calling out: this behavior refuses to run at all if you haven't explicitly called setTenantId() first, throwing rather than silently returning unscoped data. Laravel's global scope pattern usually reads the tenant from a container-bound singleton instead, which is more convenient but fails quietly if that binding is ever missing. Neither approach is wrong, but they reflect a real difference in how much each framework's community tends to favour explicit failure over convenient defaults.

Where the resemblance stops

Authentication and authorization is where "just like Laravel" breaks down fastest. Laravel bundles authentication (who is this) and authorization (what can they do) into a tightly coupled pair: guards and gates, or Sanctum plus policy classes, usually configured in one or two files and often used somewhat interchangeably in casual conversation.

CakePHP treats them as two entirely separate plugins with no shared configuration surface: cakephp/authentication and cakephp/authorization. You wire them independently in your Application class, and a controller can use one without the other. In the SaaS project, that separation is deliberate and visible in the code, not just in the plugin names:

class BusinessPolicy
{
    public function canEdit(IdentityInterface $identity, Business $business): bool
    {
        return $identity->get('business_id') === $business->id;
    }

    public function canCheckout(IdentityInterface $identity, Business $business): bool
    {
        // Billing is a form of "editing business details" in this app's
        // model, not a separate permission tier.
        return $this->canEdit($identity, $business);
    }
}

A public controller in this same app has to call skipAuthorization() explicitly if it has no policy to check, or the request fails loudly with an AuthorizationRequiredException before the response ever leaves the app. Laravel has nothing equivalent to that fail-closed guarantee: an unguarded route in Laravel is simply a route with no middleware, and there's no framework-level mechanism that notices the omission and refuses to respond. That's not a criticism of Laravel, it's a genuinely different design philosophy, and it's the kind of thing you only notice once you've built the same feature in both.

Validation rules split the same way. Laravel puts field-shape validation (is this a valid email, is this required) and business rules (is this value actually available) in the same Form Request or controller validation call. CakePHP splits them into two distinct concerns on the Table class: a Validator for shape, and a separate RulesChecker for anything that needs to query the database:

public function validationDefault(Validator $validator): Validator
{
    $validator
        ->email('email')
        ->requirePresence('email', 'create')
        ->notEmptyString('email');

    return $validator;
}

public function buildRules(RulesChecker $rules): RulesChecker
{
    $rules->add([$this, 'isEmailUnique'], 'isEmailUnique', [
        'errorField' => 'email',
        'message' => __('This email address is already in use.'),
    ]);

    return $rules;
}

That split isn't cosmetic. It caught a real bug in this project: the built-in isUnique() rule runs through the table's normal, tenant-scoped find, so it could only ever see users inside the one tenant being created during signup, never a duplicate email belonging to a different business. A genuine duplicate reached the database's own unique index and surfaced as a raw SQL error instead of a validation message. Laravel's unique validation rule has an equivalent blind spot if you scope it incorrectly, but the two-layer split in CakePHP is what made the actual failure mode obvious once I went looking: the rule I needed had to reach past the table's own scoping with an explicit unscoped query, something a single combined validation call in Laravel would have hidden inside one opaque rule.

The plugin systems solve different problems

Both frameworks call their extension mechanism a "package" or "plugin," and both let you pull shared code out of an app into something reusable. The intent is the same; the mechanics diverge completely.

A Laravel package is a Composer package with a service provider that registers bindings, publishes config, and optionally auto-discovers itself. A CakePHP plugin is a self-contained sub-application with its own namespace, its own routes scope, its own console commands, and its own place in the load order, registered with a single call:

public function bootstrap(): void
{
    parent::bootstrap();

    $this->addPlugin('TenantScope');
}

Extracting the tenant behavior above into its own plugins/TenantScope plugin meant more than moving a file. It needed its own PSR-4 autoload entry, its own linter scan path, and its own PHPUnit testsuite entry, none of which happen automatically just from creating the plugins/ directory. Laravel's equivalent (turning a trait or a model concern into a package) has a lighter checklist: a composer.json, a service provider, done. CakePHP's plugin system asks for more ceremony upfront in exchange for a plugin that can carry its own routes, middleware, and console commands as first-class citizens rather than something bolted on through a service provider's boot() method.

What this actually means if you're moving between the two

  • Expect the request lifecycle, routing conventions, and MVC folder structure to feel immediately familiar, that's the shared Rails-derived ancestry doing its job
  • Don't expect Eloquent fluency to transfer to CakePHP's Table/Entity split, or vice versa. The query builder syntax looks similar; the underlying object responsibilities (a Table is not a Model in the Eloquent sense) are genuinely different
  • Look for the seam between authentication and authorization explicitly in CakePHP. Laravel developers often conflate the two because Laravel's tooling makes it easy to; CakePHP won't let you
  • Read `buildRules()` as "the validation that needs a database round trip," not as a second, redundant validation layer bolted onto the Validator
  • Treat CakePHP's plugin system as closer to a Laravel package plus a mini Laravel app bundled together, not as a lighter-weight version of a service provider

Summary

Laravel and CakePHP are not parent and child. They're two frameworks that separately inherited the same convention-over-configuration ideas Rails put into the wider industry's water supply, then built genuinely different answers to authentication, validation, and extensibility on top of that shared starting point. The surface-level familiarity is real and worth trusting when you're learning the other framework; the deeper architectural decisions are not, and worth studying on their own terms rather than mapping term for term.

If you're building something similar, translating a team's Laravel instincts onto a CakePHP codebase or the other way round, and want a second pair of eyes on the architecture, get in touch via the contact section.