From CakePHP to Laravel
Replacing a legacy CakePHP supply chain application with Laravel and React: separating business domains, rebuilding the schema history, and moving public routes to ULIDs.
Working Out What We Had to Keep
The hardest part of replacing this application was deciding how much of it still needed to exist.
It was an internal supply chain system serving hundreds of corporate and franchise locations. It handled predictive ordering, inventory, cold-chain distribution, and automated franchisee invoicing. Those workflows ran through an aging CakePHP application, with years of changes layered over the original implementation.
A substantial amount of that code was dead. There were abandoned promotional hooks, unfinished features, and older versions of workflows that had been replaced without being removed. The difficulty was telling those apart from code that ran infrequently but still mattered to operations.
An unfamiliar branch in an ordering calculation might be obsolete. It might also account for an exception that only appeared under a particular ordering schedule. Removing it required understanding the rule, finding its callers, and checking whether the business still used it. Copying it into Laravel without that investigation would leave the same question for the next developer.
That was the work underneath the rewrite. The application was already doing a necessary job. We needed to understand that job well enough to separate current requirements from the revisions accumulated around them.
I wanted the replacement to make those distinctions easier to see. A developer working on inventory should be able to find the inventory rules, understand their inputs, and test a change without tracing an unrelated 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 received considerable attention over the years, and preserving that performance was a requirement.
There were no database-level foreign keys. Relationships were maintained in PHP, with the consistency of the data depending on how each caller used the tables.
For example, an indexed invoice_id column made it efficient to retrieve the lines belonging to an invoice. It did not stop an import, a background job, or another application path from inserting a line whose invoice did not exist. Even a caller that checked for the parent first could leave a gap between that check and the insert unless its transaction and locking behavior accounted for concurrent changes.
Adding constraints meant revisiting assumptions the application had been making for years. Was a missing parent valid for a draft? Did an empty reference mean “not assigned yet”? Could the parent be deleted after downstream records had been created? A column name could suggest an answer, but the workflow had to confirm it.
This also put a useful limit on what the database work could accomplish. A foreign key can establish that an invoice exists. It cannot, by itself, establish that the current user is allowed to add a line to it or that its current status permits editing. We still needed those rules in the application. The constraints would provide a consistent floor beneath them, including for code that bypassed Eloquent.
Giving the Business Rules Somewhere to Live
Laravel provided the framework for the replacement, and Domain-Driven Design helped organize the parts of the application that were difficult to separate in CakePHP.
The domains came from the work the system performed. Inventory dealt with stock movements, reservations, and availability. Replenishment used demand and ordering policies to determine requirements. Logistics handled fulfillment and distribution. Invoicing dealt with the financial records resulting from those activities.
Writing those names into a directory structure was straightforward. Deciding what could cross between them took more thought.
Take a change to a fulfilled quantity. Inventory needs to account for the stock movement. Logistics needs an accurate fulfillment record. Invoicing may need to use the confirmed quantity when preparing a charge. Those concerns are related, but their rules are different. Letting each domain reach into the others’ tables would keep that coupling in place, even with separate namespaces.
We needed defined operations and clear inputs between those areas. Invoicing should receive the information required to produce a financial record, rather than reconstruct the entire fulfillment workflow whenever it needs a quantity. Historical financial data also needs to remain meaningful when operational records change later.
We kept the backend together as a modular Laravel application. That let us use local calls and database transactions where work needed to succeed or fail together. Domain boundaries made responsibilities explicit without requiring a separately deployed service for each one.
I also wanted to keep the domain code readable for the people who would maintain it. Request handling, transaction coordination, business rules, and persistence have different jobs. Separating them gave the rules a place where they could be tested without building an HTTP request or rendering a page. It did not require wrapping every framework call in another abstraction.
The existing application remained a reference while those rules were extracted. Characterization tests helped capture active behavior before it was changed. Where the old behavior was unclear, the useful question was what the business expected, with the old code providing evidence rather than the final answer.
Bringing the Schema Into the Migration History
We generated a Laravel migration baseline from the existing tables. This gave us a versioned description of the schema and a way to reproduce it in a new environment.
The generator handled the repetitive work. Reviewing its output still mattered. Integer signedness, decimal precision, defaults, nullability, collations, and index order all needed to match the existing database. A generated migration that looked reasonable could still produce a different schema.
For this kind of reverse engineering, a tool such as Laravel Migrations Generator reads the existing database metadata. Laravel’s regular make:migration command only creates the migration scaffold.
We kept the baseline faithful to the inherited structure and put the hardening changes in subsequent migrations. That made it possible to review an added constraint as a specific change, rather than trying to spot it among the definitions of every existing table.
The baseline also needed different treatment on an empty database and on production. A new environment could run the table-creation migrations. Production already had those tables, so it needed the baseline verified and recorded as applied before running the later changes. Replaying the creation migrations against the live schema would be incorrect.
Making a relationship explicit
This simplified example shows the distinction. The table names are illustrative.
An application can use a relationship without the database enforcing it:
SELECT id, invoice_id, quantity
FROM invoice_lines
WHERE invoice_id = :invoice_id;
In Laravel, the model can express how to retrieve the related records:
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Invoice extends Model
{
public function lines(): HasMany
{
return $this->hasMany(InvoiceLine::class, 'invoice_id');
}
}
The database relationship is a separate declaration. For a fresh schema, with the parent table already created:
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
Schema::create('invoice_lines', function (Blueprint $table): void {
$table->id();
$table->foreignId('invoice_id')
->constrained('invoices')
->restrictOnDelete();
$table->decimal('quantity', 14, 4);
});
hasMany makes the relationship available to the application. constrained() creates the foreign key. Defining an Eloquent relationship alone does not change the database schema. Laravel documents these separately under relationships and migration constraints.
For an existing table, the column is already there. 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();
});
That distinction matters with legacy identifiers. foreignId() creates an unsigned big integer column. An existing schema may use another type, and the referencing and referenced columns need compatible definitions. Changing them simply to fit a framework convention would introduce additional migration work.
Getting existing data ready
The constraint declaration was usually the short part. Before adding it, we had to establish what the relationship meant, check existing records, and make sure current writers followed the intended rule.
Orphan checks could be run in bounded primary-key ranges. The results then needed interpretation. A financial record with a missing parent might require reconciliation; deleting it just to satisfy a constraint could remove information the business still needed.
An audit also describes the data at a point in time. If writes continue, inconsistencies can appear before the constraint is installed. 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 required similar care. Cascade behavior was suitable where child records had no independent lifecycle, such as disposable draft details. Historical and financial records needed restrictive rules. The restrictOnDelete() in the example is intentional: deleting an invoice should not silently remove its lines.
We also reviewed engine compatibility, required values, uniqueness, and handling of invalid input. Enabling stricter behavior can expose assumptions in old writers, particularly where they have relied on implicit conversions or incomplete records.
Keeping the operational cost visible
Laravel makes the DDL easy to express, but the database still determines how it executes. The generated SQL needed rehearsal against the actual MySQL version and representative data.
For example, MySQL 8.4 requires the COPY algorithm when adding a foreign key with checks enabled; its INPLACE path requires disabling those checks. That affects the rollout options substantially. The online DDL documentation describes those restrictions.
Turning checks off also leaves an important responsibility with the migration process: enabling foreign_key_checks again does not retroactively validate existing rows. MySQL documents that behavior explicitly.
We preserved the established query performance while introducing the constraints. That involved retaining useful indexes, avoiding redundant ones, and checking the workload after structural changes. Write throughput, lock waits, replication lag, and batch completion time mattered alongside query latency. An existing fast read path could coexist with new contention on writes, so checking only the read queries would miss part of the effect.
Separating the Screens From the Workflows
The React frontend gave us a chance to untangle the interface from the application logic at the same time.
In a coupled server-rendered application, a form and its handler can gradually become the only complete description of an operation. Particular field combinations, validation branches, and redirect behavior explain how the workflow is supposed to work. Extracting an API forces those assumptions to become explicit.
An inventory adjustment, for example, needs a defined request, permissions, validation rules, and a result. Laravel handles the operation. React presents the inputs and explains what happened. The same backend behavior can then be tested without reproducing the screen that originally initiated it.
That also makes failure states easier to discuss during implementation. An operator can open a screen while another process changes the underlying inventory. The interface needs to handle the resulting conflict and retain useful input, rather than treating every rejected request as an unexpected error.
The reactive UI improved those interactions, but the API remained responsible for deciding which changes were valid. Frontend validation could provide quicker feedback; server-side rules and transactions still determined whether an operation completed.
Changing the identifiers in public URLs
Some views were available publicly, even though most of the application supported internal operations. Those routes exposed sequential numeric IDs, which made records easy to enumerate. Given a URL such as /view/410, someone could try nearby values without knowing anything else about the application.
We changed the public routes to use ULIDs. The URLs no longer exposed the database’s incrementing record numbers. This was a useful detail to address while defining the new interface: the identifier used to find a record internally does not have to be the identifier presented in a public link.
That change addressed the exposed numeric sequence. Access rules remained a separate concern. An intentionally public view should return only the information intended for publication; a restricted view still needs to check permission for the requested record, even when the caller supplies a valid ULID. A more complex identifier alone does not fix missing authorization, a distinction covered in OWASP’s guidance on direct object references.
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. Laravel also provided APIs for validation and other routine application work that had previously required custom implementations. We could use and configure those framework features instead of carrying that code into the replacement.
The code we wrote could concentrate on the parts specific to the business: replenishment calculations, inventory rules, fulfillment transitions, and invoicing. Tests covered those rules and their interaction with persistence, while the domain boundaries made it clearer where a change belonged.
There was still plenty of business logic to maintain. But a developer changing a replenishment rule had less unrelated application machinery to work through, and tests available to check the behavior being changed.
Comments
Loading comments…