← all posts

From CakePHP to Laravel

How I moved a legacy CakePHP supply chain app to Laravel and React.

Working Out What We Had to Keep

The system was an internal supply chain application serving hundreds of corporate and franchise locations. It handled predictive ordering, inventory, cold-chain distribution, and automated franchisee invoicing, all through an aging CakePHP codebase.

Much of that code was dead: abandoned promotional hooks, unfinished features, and old versions of workflows that had been replaced but never removed. The hard part was telling it apart from code that ran rarely but still mattered. An odd branch in an ordering calculation might be obsolete, or it might handle an exception that only appears under one ordering schedule. Copying it into Laravel without finding out would leave the same question for the next developer.

The goal for the replacement followed from that. A developer working on inventory should be able to find the inventory rules, understand their inputs, and test a change without tracing an invoicing path through a controller.

A Fast Database With Implicit Relationships

The MySQL database had 120 tables and roughly 600 GB of data. Its queries and indexes had been tuned over the years, and keeping that performance was a requirement.

There were no foreign keys. Relationships lived in PHP, so data consistency depended on every caller doing the right thing. An indexed invoice_id made it fast to fetch an invoice’s lines, but nothing stopped an import or a background job from inserting a line for an invoice that did not exist.

Adding constraints meant answering questions the application had avoided for years. Was a missing parent valid for a draft? Did an empty reference mean “not assigned yet”? Could a parent be deleted once downstream records existed? Column names suggested answers; the workflows had to confirm them.

Constraints also have limits. A foreign key can establish that an invoice exists, not that the current user may add a line to it or that its status allows editing. Those rules stayed in the application. The constraints gave them a consistent floor, including for code that bypassed Eloquent.

Giving the Business Rules Somewhere to Live

We used Laravel with Domain-Driven Design to organize what had been hard to separate in CakePHP. The domains came from the work the system did. Inventory covered stock movements, reservations, and availability. Replenishment turned demand and ordering policies into requirements. Logistics handled fulfillment and distribution, and Invoicing the financial records that resulted.

Creating those directories was easy. Deciding what could cross between them took more thought. A change to a fulfilled quantity matters to inventory, logistics, and invoicing, each with different rules. Letting each domain reach into the others’ tables would keep that coupling in place despite the separate namespaces. Instead, domains worked through defined operations with clear inputs, and Invoicing received what it needed to produce a financial record rather than reconstructing the fulfillment workflow.

The backend stayed a single modular Laravel application, so work that had to succeed or fail together could share a database transaction. Request handling, transactions, business rules, and persistence were kept apart, which let us test the rules without building an HTTP request.

The old application remained a reference while the rules were extracted. Characterization tests captured active behavior before we changed it. Where behavior was unclear, we asked what the business expected and treated the old code as evidence rather than the final answer.

Bringing the Schema Into the Migration History

We generated a Laravel migration baseline from the existing tables with Laravel Migrations Generator. The output still needed review. Signedness, decimal precision, defaults, nullability, collations, and index order all had to match, and a reasonable-looking migration could still produce a different schema.

The baseline stayed faithful to the inherited structure, and each hardening change went into its own later migration so it could be reviewed on its own. New environments ran the baseline. Production had it verified and recorded as applied, since replaying table creation against the live schema would be wrong.

Defining a hasMany relationship in Eloquent does not change the database; the foreign key is a separate declaration. On an existing table the column is already there, so once its data, type, and indexes have been checked, the migration adds just the constraint:

Schema::table('invoice_lines', function (Blueprint $table): void {
    $table->foreign('invoice_id', 'invoice_lines_invoice_fk')
        ->references('id')
        ->on('invoices')
        ->restrictOnDelete();
});

Laravel’s foreignId() shortcut creates an unsigned big integer column, which a legacy schema may not use. The referencing and referenced columns need compatible definitions, and changing them just to fit a framework convention adds migration work.

Getting existing data ready

The constraint itself was usually the short part. First we checked existing records for orphans in bounded primary-key ranges, then interpreted the results. A financial record with a missing parent might need reconciliation; deleting it just to satisfy a constraint could remove information the business still needed.

An audit only describes the data at one point in time. If writes continue, new inconsistencies can appear before the constraint is installed, so the rollout has to close that gap, for example with a controlled pause in the affected writes and a final validation before enforcement.

Delete rules needed the same care. Cascades suited child records with no independent lifecycle, such as draft details. Historical and financial records got restrictive rules, which is why the example uses restrictOnDelete(): deleting an invoice should not silently remove its lines.

Keeping the operational cost visible

Laravel makes the DDL easy to express, but MySQL decides how it executes, so we rehearsed the generated SQL against the actual MySQL version and representative data. In MySQL 8.4, adding a foreign key with checks enabled requires the COPY algorithm, and the INPLACE path requires disabling those checks, as the online DDL documentation describes. Enabling foreign_key_checks again does not retroactively validate existing rows, which MySQL documents explicitly, so the migration process has to cover that.

We tracked write throughput, lock waits, replication lag, and batch completion time alongside query latency. A fast read path can coexist with new contention on writes, so checking only the read queries would miss part of the effect.

Separating the Screens From the Workflows

In the old server-rendered application, a form and its handler were often the only complete description of an operation. Moving to a React frontend forced those rules into the open. An inventory adjustment became a defined request with permissions, validation rules, and a result. Laravel performs the operation, React collects the inputs and reports what happened, and the backend behavior can be tested without the screen. Frontend validation gives quicker feedback, but the API decides which changes are valid.

A few views were public and exposed sequential numeric IDs, so a URL such as /view/410 invited trying nearby values. We changed the public routes to use ULIDs. That removes the enumerable sequence, but it is not access control: restricted views still check permission for each requested record, as OWASP’s guidance on direct object references explains.

Less Custom Code to Maintain

The final application contained around 65,000 lines of handwritten code, including the unit and integration tests. Removing unused features accounted for some of the reduction, and Laravel’s validation and other routine features replaced custom implementations we would otherwise have carried over.

What remained concentrated on the parts specific to the business: replenishment calculations, inventory rules, fulfillment transitions, and invoicing. Tests cover those rules, and the domain boundaries make it clearer where a change belongs.

Comments

Loading comments…

← older
Resource requests are load-bearing
newer →
PHP2Shell: what to patch and what to contain