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
LaravelCakePHPDevelopmentPHPArchitecture guide

Moving a legacy PHP or CakePHP application to Laravel: a realistic plan

Migrate a legacy PHP or CakePHP app to Laravel without a rewrite: one router, a shared database, a signed login bridge, tests first and one module per release.

Cloudsoft Zone

Updated 12 min read

Strangler fig roots forming a dense lattice around a large boulder in rainforest at Katandra Reserve, New South Wales
Photograph: O. Baudys / CC BY 3.0, via Wikimedia Commons
In this article

The realistic way to move a legacy PHP or CakePHP application to Laravel is not a rewrite. It is a strangler fig migration: put one router in front of both applications, send one feature at a time to a new Laravel app that reads the same database, share login between them, and switch the old app off only when nothing routes to it any more. Every step ships to production and every step can be rolled back by changing one router rule. This article lays out that plan in detail: the routing, auth bridging, database reuse, how CakePHP concepts map to Laravel, the test-first discipline that keeps it safe, phasing, cost and risk, and when you should not migrate at all.

Tested with: Laravel 13 on PHP 8.5 for the guard, login and model code (run in a scratch application with PHPUnit 12); the legacy-side token function is written to run on PHP 7.3 or later. The nginx configuration was checked against the nginx proxy module documentation but not run here.

Why not a big-bang rewrite

A rewrite freezes the old application while the new one catches up, and the old one keeps changing because the business does. The cut-over then happens all at once, with years of undocumented behaviour discovered in production. Martin Fowler's Strangler Fig Application describes the alternative: grow the new system around the edges of the old one until the old one can be removed. It suits legacy PHP well, because PHP applications are request-scoped and share nothing between requests except the database, the session store and cookies, which are exactly the things you can share deliberately.

The architecture: one router, two apps, one database

Architecture diagram: nginx routes /account, /invoices and /api/v2 to Laravel and other paths to the legacy CakePHP app, both using one MySQL database
One router decides which application answers each path; both read the same database and share identity through a signed cookie.

Three pieces make this work:

  • An edge router (nginx, a load balancer, or Cloudflare rules) that sends specific URL prefixes to Laravel and everything else to the legacy app. Users see one domain.
  • A shared database. Laravel starts by reading and writing the existing tables. You do not migrate data on day one.
  • Shared identity. A user who logs in on one side must be logged in on the other. A short-lived signed cookie does this with very little code.

Routing with nginx

Run each application on its own internal port (its own nginx and PHP-FPM pool, or its own container) and let a single public server pick the backend. A map on the request path keeps the routing table in one readable place. When proxy_pass contains a variable, nginx looks the name up among the defined upstream groups first, as the proxy module documentation describes.

Nginx
upstream legacy  { server 127.0.0.1:8081; }   # CakePHP or plain PHP app
upstream laravel { server 127.0.0.1:8082; }   # new Laravel 13 app

# One place that says which paths have moved.
map $uri $backend {
    default              legacy;
    ~^/account(/|$)      laravel;
    ~^/invoices(/|$)     laravel;
    ~^/api/v2/           laravel;
    ~^/build/            laravel;   # Laravel's Vite assets
}

server {
    listen 443 ssl;
    http2 on;
    server_name app.example.com;
    # ssl_certificate and ssl_certificate_key as usual

    location / {
        proxy_pass http://$backend;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Two rules prevent most routing bugs. First, a form and the URL it posts to must live in the same application, because each app has its own CSRF tokens. Move a feature's GET and POST routes together. Second, tell Laravel it sits behind a proxy ($middleware->trustProxies(at: '127.0.0.1') in bootstrap/app.php) so URLs, HTTPS detection and client IPs are correct.

Bridging sessions and login

The two apps cannot read each other's sessions safely: CakePHP and Laravel serialise session data differently and use different cookie names. Instead of sharing the session, share a small, signed statement of who the user is. While login still lives in the legacy app, it sets a cookie after a successful login (and refreshes it on each request), signed with an HMAC secret both apps know:

PHP
<?php
// Legacy app (PHP 7.3+): call after login and on each authenticated request.
function bridge_token($userId, $secret, $ttl = 900)
{
    $json = json_encode(array('uid' => (int) $userId, 'exp' => time() + $ttl));
    $payload = rtrim(strtr(base64_encode($json), '+/', '-_'), '=');

    return $payload . '.' . hash_hmac('sha256', $payload, $secret);
}

setcookie('bridge_auth', bridge_token($userId, getenv('LEGACY_BRIDGE_SECRET')), array(
    'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax',
));

On the Laravel side, a closure request guard verifies the signature and expiry and loads the user. Keep the verification in a small class so it can be unit tested:

PHP
<?php

namespace App\Auth;

final class LegacyBridgeToken
{
    /** Returns the user id from a valid, unexpired token, or null. */
    public static function userId(string $token, string $secret): ?int
    {
        [$payload, $signature] = array_pad(explode('.', $token, 2), 2, '');

        if ($payload === '' || $secret === '') {
            return null;
        }

        $expected = hash_hmac('sha256', $payload, $secret);
        if (! hash_equals($expected, $signature)) {
            return null;
        }

        $data = json_decode((string) base64_decode(strtr($payload, '-_', '+/'), true), true);
        if (! is_array($data) || ! is_int($data['uid'] ?? null) || ($data['exp'] ?? 0) < time()) {
            return null;
        }

        return $data['uid'];
    }
}
PHP
<?php
// app/Providers/AppServiceProvider.php
public function boot(): void
{
    Auth::viaRequest('legacy-bridge', function (Request $request): ?User {
        $userId = LegacyBridgeToken::userId(
            (string) $request->cookie('bridge_auth', ''),
            (string) config('services.legacy_bridge.secret'),
        );

        return $userId === null ? null : User::find($userId);
    });
}

// config/auth.php, in 'guards':        'legacy' => ['driver' => 'legacy-bridge'],
// config/services.php:                 'legacy_bridge' => ['secret' => env('LEGACY_BRIDGE_SECRET')],
// bootstrap/app.php, withMiddleware:   $middleware->encryptCookies(except: ['bridge_auth']);
// routes:                              Route::middleware('auth:legacy')->group(...);

The encryptCookies(except: ...) line matters: Laravel encrypts and signs every cookie by default, so a cookie written by another application would otherwise be rejected. We tested the guard with a valid token, a token signed with the wrong secret and an expired token; only the first authenticates. One testing detail: Laravel's getJson() does not send cookies unless you call withCredentials(), which can make a broken guard look correct.

Security notes: generate the secret with at least 32 random bytes, keep it in both apps' environment (never in the repository), keep the token lifetime short, and delete the cookie on logout in both apps. Once login moves to Laravel (phase 2 below), reverse the direction: Laravel issues the cookie and the legacy app verifies it with the same few lines.

Moving password hashes

CakePHP 3 and later hash passwords with PHP's password_hash() (bcrypt by default), which Laravel's hasher verifies directly. CakePHP 2's SimplePasswordHasher defaulted to a salted SHA-1 using Security.salt, which Laravel cannot verify; with bcrypt verification enabled (the default) Laravel throws "This password does not use the Bcrypt algorithm" rather than silently failing. Verify old hashes yourself once, then rehash:

PHP
<?php

namespace App\Auth;

use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;

final class LegacyAwareLogin
{
    public function attempt(string $email, string $password): bool
    {
        $user = User::where('email', $email)->first();

        if ($user === null) {
            Hash::check($password, '$2y$12$'.str_repeat('a', 53)); // similar timing

            return false;
        }

        // CakePHP 3+ used password_hash() (bcrypt): Laravel verifies it directly.
        if (str_starts_with($user->password, '$2y$')) {
            return Auth::attempt(['email' => $email, 'password' => $password]);
        }

        // CakePHP 2 SimplePasswordHasher default: sha1(Security.salt . password).
        $legacy = sha1(config('services.legacy_bridge.cake2_salt').$password);
        if (! hash_equals($user->password, $legacy)) {
            return false;
        }

        $user->forceFill(['password' => Hash::make($password)])->save();
        Auth::login($user);

        return true;
    }
}

Check what your legacy app actually used before copying this: some CakePHP 2 apps configured a different hash type or the Blowfish hasher. Run a report of hash prefixes in the users table first, and plan a forced reset for accounts that never log in during the migration.

This is the point where most migrations get into trouble: two apps, two session systems, and old password formats. If your team has not done this before, a legacy application modernisation review of the auth bridge and router rules before the first slice goes live is cheap insurance.

Reusing the existing database

Eloquent can sit on CakePHP tables with very little configuration, because both frameworks use plural snake_case tables and singular_id foreign keys. The main difference is timestamps: CakePHP's Timestamp behaviour writes created and modified, Laravel expects created_at and updated_at.

PHP
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Invoice extends Model
{
    // CakePHP Timestamp behaviour column names
    public const CREATED_AT = 'created';
    public const UPDATED_AT = 'modified';

    protected $table = 'invoices';

    protected $fillable = ['customer_id', 'number', 'status', 'total'];

    protected function casts(): array
    {
        return [
            'total' => 'decimal:2',
            'issued_on' => 'date',
        ];
    }

    public function customer(): BelongsTo
    {
        return $this->belongsTo(Customer::class);
    }

    public function lines(): HasMany
    {
        return $this->hasMany(InvoiceLine::class);
    }
}

Rules that keep a shared database sane:

  • One owner per table for schema changes. While both apps run, only one side changes a table's structure, and the other is updated in the same release.
  • Additive changes only until the legacy code that reads a column is gone: add columns and tables, do not rename or drop.
  • Baseline the schema in Laravel with php artisan schema:dump. Laravel writes the current schema to database/schema/ and loads it before running migrations on an empty database, so tests get the real legacy schema. New changes then go in normal Laravel migrations; leave CakePHP's phinxlog table alone.
  • Business rules live in code, not just in the database. CakePHP callbacks such as beforeSave and behaviours like CounterCache update data silently. List them per table and reproduce them in Laravel (observers, model events or explicit service code) before Laravel writes to that table.

Mapping CakePHP concepts to Laravel

CakePHPLaravel equivalentWatch out for
Controller and actionsController, routes in routes/web.phpCake's convention routing; list every reachable URL first
ComponentMiddleware, a service class, or a controller traitComponents that change the request or response
Table class (finders, validation, associations)Eloquent model, query scopes, Form Request validationCustom finders that hide business rules
Entity (virtual fields, $_accessible)Model accessors, $fillableMass assignment differences
Behaviour (Timestamp, Tree, CounterCache)Built-in timestamps, model traits, observersSide effects on save and delete
Helper, element, cellBlade components and view composersHTML escaping defaults differ
Shell or Command, cron entriesArtisan commands and the schedulerCrons that call URLs instead of commands
Authentication and Authorization pluginsGuards, Gates and PoliciesRules spread across controllers
Fixtures and TestCaseFactories, RefreshDatabase, feature testsFixture data that tests silently depend on
Phinx migrations, bin/cake bakeLaravel migrations, php artisan make:*Do not replay old migrations; baseline instead

Plain PHP applications without a framework map the same way, but expect more of the work to be finding where logic lives: include files, global functions and SQL built in templates.

Test first, then move

Before a feature moves, write tests that describe what the legacy feature does today, run them against the legacy app, and only then implement the Laravel version and run the same tests against it. HTTP-level tests work best because they do not care which framework answers: for each route, assert status codes, key content, redirects, and the database rows that should change. Browser tests (for example Playwright) cover the flows customers actually use, such as checkout, registration and password reset.

Then measure parity in production. For read-only pages you can shadow traffic or compare responses for a sample of real requests; for write flows, compare the rows each app writes for the same input in staging. Keep the legacy route switchable for at least one release after the move.

Phasing the migration

Timeline of five migration phases: prepare, first read-only slice, auth, modules one per release, and retiring the legacy app
Each phase ships to production and can be rolled back by changing one router rule.
  1. Phase 0, prepare: put the router in front with every path still going to legacy, set up CI and deployments for the Laravel app, write the first characterisation tests, and inventory routes, crons, queues, integrations and outgoing emails.
  2. Phase 1, first slice: move a read-only, low-risk area (an account overview or a report) to prove routing, auth bridging, shared database access and deployment.
  3. Phase 2, auth: move login, logout, registration and password reset to Laravel, rehash passwords on login, and reverse the bridge cookie direction.
  4. Phase 3, modules: move write flows one module per release, along with their crons and background jobs. Freeze new features in the legacy app; new work is built only in Laravel.
  5. Phase 4, retire: when the router's default rule serves nothing important, switch the default to Laravel, keep the legacy app read-only for a period, then archive it and remove the bridge.

Cost and risk

We will not quote a generic figure, because the cost depends on things you can count. Estimate from the inventory: number of routes and forms, tables with behaviours or callbacks, cron jobs and integrations (payment gateways, SMS, email providers, accounting exports), and how much of the logic is covered by tests today. Areas with no tests and heavy business rules (pricing, tax, invoicing) cost the most, regardless of size.

RiskMitigation
Hidden behaviour in callbacks and behavioursPer-table list of side effects; characterisation tests before moving writes
Both apps writing the same rows differentlyOne owner per table; move all writes for a table in one release
Login loops or users logged out unexpectedlyBridge cookie tested on staging with real browsers; short TTL with refresh
The migration stalls halfwayEvery phase is useful on its own; stop and run both apps if needed
SEO loss from changed URLsKeep URLs identical; where they change, add permanent redirects in Laravel

Common mistakes

  • Starting with the hardest module to "get it over with". Start where the risk is lowest and prove the plumbing.
  • Sharing the PHP session between frameworks instead of a signed identity cookie.
  • Letting the legacy app keep getting new features, so the target keeps moving.
  • Renaming columns while legacy code still reads them.
  • Forgetting crons, queue consumers and scheduled emails, which keep running in the legacy app long after its pages are gone.

When not to migrate

Do not migrate just because Laravel is more popular. If the application runs on CakePHP 4 or 5, CakePHP is actively maintained (5.4 was released in July 2026), and upgrading within CakePHP is usually far cheaper; our CakePHP 4 to 5.4 upgrade checklist covers that path. Also hold off if the application changes rarely and only needs security patches, if nobody can describe what it does well enough to write tests, or if the business plans to replace it with an off-the-shelf product within a year or two. A migration is justified by a cost you are paying today: slow delivery, hiring difficulty, or a framework or PHP version that no longer gets security fixes.

Cloudsoft Zone runs legacy PHP and CakePHP to Laravel migrations as a sequence of small releases: router and auth bridge first, then one tested module at a time, with the old application kept working until the last route moves. See legacy application modernisation.

Frequently asked questions

  • Can Laravel use the existing CakePHP database tables?

    Yes. Both frameworks use plural snake_case tables and singular_id foreign keys. Set CREATED_AT = 'created' and UPDATED_AT = 'modified' on models to match CakePHP's Timestamp behaviour, and use php artisan schema:dump to baseline the schema for tests.

  • How do users stay logged in across both applications?

    Share a short-lived HMAC-signed cookie carrying the user id and expiry rather than the session itself. The other app verifies it with a closure request guard (Auth::viaRequest) and the cookie is excluded from Laravel's cookie encryption.

  • Will CakePHP password hashes work in Laravel?

    Hashes from CakePHP 3 and later use password_hash() (bcrypt) and verify directly. CakePHP 2's default salted SHA-1 hashes need a one-time legacy check at login, followed by rehashing with Hash::make().

  • Should I migrate a CakePHP 5 application to Laravel?

    Usually not. CakePHP 5 is actively maintained, and upgrading within CakePHP is cheaper. Migrate only when there is a concrete cost, such as hiring difficulty or slow delivery, that Laravel would remove.

  • How do I roll back a migrated feature?

    Point its path back to the legacy upstream in the router map and reload nginx. This only works if both apps still write compatible data, which is why schema changes stay additive until the legacy code is gone.

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. Strangler Fig Application — Martin Fowler
  2. Module ngx_http_proxy_module (proxy_pass with variables) — nginx
  3. Laravel 13 authentication: closure request guards and password rehashing — Laravel
  4. Laravel 13 responses: cookies and encryption — Laravel
  5. Laravel 13 migrations: squashing migrations (schema:dump) — Laravel
  6. Laravel 13 Eloquent: timestamps and conventions — Laravel
  7. CakePHP releases — CakePHP on GitHub, 2026-07-29
  8. CakePHP 2.10 Security::hash (salted SHA-1 default) — CakePHP on GitHub
  9. CakePHP Authentication: DefaultPasswordHasher — 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.