{"id":64,"date":"2026-07-14T10:15:00","date_gmt":"2026-07-14T04:45:00","guid":{"rendered":"https:\/\/softcrony.com\/blog\/?p=64"},"modified":"2026-07-14T10:15:00","modified_gmt":"2026-07-14T04:45:00","slug":"legacy-php-to-laravel-12-migration-case-study","status":"publish","type":"post","link":"https:\/\/softcrony.com\/blog\/legacy-php-to-laravel-12-migration-case-study\/","title":{"rendered":"How We Migrated a Client&#8217;s Legacy PHP System to Laravel 12 Without Downtime"},"content":{"rendered":"<p>Legacy PHP migration is one of the most technically challenging and politically sensitive projects in software development. The system works. People depend on it. And every day it runs, it accumulates more technical debt.<\/p>\n<p>This is how we migrated a 10-year-old custom PHP application to Laravel 12 for a distribution company in Madhya Pradesh \u2014 without taking the system offline for a single hour.<\/p>\n<h2>The Client and the Problem<\/h2>\n<p>A mid-sized FMCG distribution company had been running a custom PHP 5.6 application since 2013. The system managed their entire operations \u2014 orders, inventory, billing, delivery tracking, and client accounts.<\/p>\n<p>By 2025, the system was showing its age:<\/p>\n<ul>\n<li>Running on PHP 5.6 \u2014 end of life since 2018, no security patches<\/li>\n<li>No framework \u2014 procedural PHP with included files and global variables<\/li>\n<li>MySQL queries built with string concatenation \u2014 SQL injection risk throughout<\/li>\n<li>No version control \u2014 the codebase existed only on the live server<\/li>\n<li>Single developer had built it and was no longer contactable<\/li>\n<li>Hosting provider threatened to drop PHP 5.6 support<\/li>\n<\/ul>\n<p>The business couldn&#8217;t stop using the system \u2014 40 staff used it daily, 200+ orders processed per day. A full rewrite with downtime was not an option.<\/p>\n<h2>The Strategy: Strangler Fig Pattern<\/h2>\n<p>We used the Strangler Fig Pattern \u2014 gradually replacing the old system piece by piece while it remained live, until the old system could be safely retired.<\/p>\n<p>The approach:<\/p>\n<ol>\n<li>Run the new Laravel system alongside the old PHP system<\/li>\n<li>Migrate one module at a time \u2014 starting with the least critical<\/li>\n<li>Use the same database during transition (both systems read\/write the same data)<\/li>\n<li>Route traffic module by module from old to new via Nginx<\/li>\n<li>Retire the old system only when all modules are migrated<\/li>\n<\/ol>\n<h2>Phase 1 \u2014 Audit and Documentation (Weeks 1\u20133)<\/h2>\n<p>We spent the first three weeks doing nothing but understanding the existing system. No code changes. No new features.<\/p>\n<p>We mapped every page, every database table, every business rule. The existing system had no documentation \u2014 everything was implicit in the code.<\/p>\n<p>Key findings:<\/p>\n<ul>\n<li>47 PHP files, approximately 28,000 lines of code<\/li>\n<li>23 database tables, several with no foreign keys or indexes<\/li>\n<li>12 distinct business modules<\/li>\n<li>3 critical business rules buried in code with no comments<\/li>\n<li>2 active SQL injection vulnerabilities in the order search feature<\/li>\n<\/ul>\n<h2>Phase 2 \u2014 Database Hardening (Weeks 4\u20135)<\/h2>\n<p>Before touching the application, we hardened the database. This work applied to both the old and new system:<\/p>\n<pre><code>-- Add missing indexes (the old system had almost none)\r\nALTER TABLE orders ADD INDEX idx_client_id (client_id);\r\nALTER TABLE orders ADD INDEX idx_created_at (created_at);\r\nALTER TABLE order_items ADD INDEX idx_order_id (order_id);\r\nALTER TABLE products ADD INDEX idx_sku (sku);\r\n\r\n-- Add foreign key constraints (previously missing)\r\nALTER TABLE order_items\r\n  ADD CONSTRAINT fk_order_items_order\r\n  FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE;\r\n\r\n-- Fix inconsistent data before migration\r\nUPDATE orders SET status = 'pending' WHERE status IS NULL;\r\nUPDATE products SET stock_quantity = 0 WHERE stock_quantity IS NULL;<\/code><\/pre>\n<p>This work improved the old system&#8217;s performance immediately \u2014 query times on the orders list dropped from 4.2 seconds to 0.3 seconds just from adding indexes.<\/p>\n<h2>Phase 3 \u2014 Laravel Setup with Shared Database (Weeks 6\u20138)<\/h2>\n<p>We set up the Laravel 12 application pointing at the same MySQL database as the old system. Laravel&#8217;s Eloquent models mapped to the existing table structure:<\/p>\n<pre><code>\/\/ app\/Models\/Order.php \u2014 maps to existing orders table\r\nclass Order extends Model\r\n{\r\n    \/\/ Use existing table structure\r\n    protected $table = 'orders';\r\n\r\n    \/\/ Map old column names to clean Laravel conventions\r\n    protected $casts = [\r\n        'created_at' => 'datetime',\r\n        'order_date' => 'date',\r\n        'total_amount' => 'decimal:2',\r\n    ];\r\n\r\n    \/\/ Relationships that didn't exist in old system\r\n    public function items(): HasMany\r\n    {\r\n        return $this->hasMany(OrderItem::class);\r\n    }\r\n\r\n    public function client(): BelongsTo\r\n    {\r\n        return $this->belongsTo(Client::class);\r\n    }\r\n}<\/code><\/pre>\n<h2>Phase 4 \u2014 Module-by-Module Migration (Weeks 9\u201320)<\/h2>\n<p>We migrated modules in order of risk \u2014 lowest risk first, so we built confidence before touching critical features:<\/p>\n<table>\n<thead>\n<tr>\n<th>Week<\/th>\n<th>Module Migrated<\/th>\n<th>Risk<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>9\u201310<\/td>\n<td>Reports and exports<\/td>\n<td>Low (read-only)<\/td>\n<\/tr>\n<tr>\n<td>11\u201312<\/td>\n<td>Product catalogue<\/td>\n<td>Low<\/td>\n<\/tr>\n<tr>\n<td>13\u201314<\/td>\n<td>Client management<\/td>\n<td>Medium<\/td>\n<\/tr>\n<tr>\n<td>15\u201316<\/td>\n<td>Inventory tracking<\/td>\n<td>Medium<\/td>\n<\/tr>\n<tr>\n<td>17\u201318<\/td>\n<td>Billing and invoices<\/td>\n<td>High<\/td>\n<\/tr>\n<tr>\n<td>19\u201320<\/td>\n<td>Order management<\/td>\n<td>Critical<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>For each module migration, we used Nginx to route traffic:<\/p>\n<pre><code># nginx.conf \u2014 route reports to new Laravel system\r\nlocation \/reports {\r\n    proxy_pass http:\/\/laravel_app;\r\n    proxy_set_header Host $host;\r\n    proxy_set_header X-Real-IP $remote_addr;\r\n}\r\n\r\n# Everything else still goes to old PHP system\r\nlocation \/ {\r\n    root \/var\/www\/legacy;\r\n    index index.php;\r\n    fastcgi_pass php56:9000;\r\n}<\/code><\/pre>\n<h2>The Hardest Part \u2014 Order Management Migration<\/h2>\n<p>The order management module was the most critical and the most complex. It had the most business logic, the most users, and processed \u20b915 lakh+ in transactions daily.<\/p>\n<p>We ran both systems in parallel for 2 weeks \u2014 every order written to the old system was also written to the new system via a sync job:<\/p>\n<pre><code>\/\/ app\/Jobs\/SyncOrderToLaravel.php\r\nclass SyncOrderToLaravel implements ShouldQueue\r\n{\r\n    public function __construct(public int $legacyOrderId) {}\r\n\r\n    public function handle(): void\r\n    {\r\n        \/\/ Read from legacy format\r\n        $legacyOrder = DB::table('orders_legacy_view')\r\n            ->where('id', $this->legacyOrderId)\r\n            ->first();\r\n\r\n        \/\/ Transform and write to new format\r\n        Order::updateOrCreate(\r\n            ['legacy_id' => $legacyOrder->id],\r\n            [\r\n                'client_id'    => $legacyOrder->client_id,\r\n                'total_amount' => $legacyOrder->total,\r\n                'status'       => $this->mapStatus($legacyOrder->status_code),\r\n                'order_date'   => Carbon::parse($legacyOrder->order_dt),\r\n            ]\r\n        );\r\n    }\r\n}<\/code><\/pre>\n<p>After 2 weeks of parallel running with zero discrepancies, we switched the Nginx routing to direct all order traffic to Laravel.<\/p>\n<h2>Phase 5 \u2014 Legacy Retirement (Week 21\u201322)<\/h2>\n<p>Once all modules were on Laravel, we kept the old system running in read-only mode for 2 weeks \u2014 just in case. Then we shut it down.<\/p>\n<p>Final state: PHP 5.6 application replaced with Laravel 12, zero downtime, zero data loss.<\/p>\n<h2>Results<\/h2>\n<table>\n<thead>\n<tr>\n<th>Metric<\/th>\n<th>Before<\/th>\n<th>After<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Average page load time<\/td>\n<td>4.8 seconds<\/td>\n<td>0.6 seconds<\/td>\n<\/tr>\n<tr>\n<td>Security vulnerabilities<\/td>\n<td>2 critical, 8 medium<\/td>\n<td>0<\/td>\n<\/tr>\n<tr>\n<td>Server errors per day<\/td>\n<td>15\u201320<\/td>\n<td>0\u20131<\/td>\n<\/tr>\n<tr>\n<td>Time to onboard new developer<\/td>\n<td>2\u20133 weeks<\/td>\n<td>1\u20132 days<\/td>\n<\/tr>\n<tr>\n<td>Deployment process<\/td>\n<td>Manual FTP upload<\/td>\n<td>Git push + CI\/CD<\/td>\n<\/tr>\n<tr>\n<td>PHP version<\/td>\n<td>5.6 (EOL)<\/td>\n<td>8.3 (current)<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<h2>Lessons Learned<\/h2>\n<p><strong>Audit before you touch anything.<\/strong> Three weeks of reading code before writing any felt slow. It saved us from 3 major mistakes we would have made if we&#8217;d rushed.<\/p>\n<p><strong>Database first.<\/strong> Improving the database benefited both systems immediately and reduced the risk of data corruption during migration.<\/p>\n<p><strong>Parallel running is worth the overhead.<\/strong> Running both systems simultaneously and comparing outputs for 2 weeks before cutover gave the client confidence and caught 2 edge cases we&#8217;d missed.<\/p>\n<p><strong>Migrate features, not code.<\/strong> We didn&#8217;t try to convert old PHP to Laravel line by line. We rebuilt each feature properly in Laravel. The result is cleaner and more maintainable.<\/p>\n<p>If you have a legacy PHP system that needs modernizing and can&#8217;t afford downtime, <a href=\"https:\/\/softcrony.com\/contact\/\">our team at Softcrony specializes in exactly this kind of migration<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Legacy PHP migration is one of the most technically challenging and politically sensitive projects in software development. The system works. People depend on it. And every day it runs, it accumulates more technical debt. This is how we migrated a 10-year-old custom PHP application to Laravel 12 for a distribution company in Madhya Pradesh \u2014 [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":65,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[5],"tags":[16,19,68,67,30,18],"class_list":["post-64","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-case-studies","tag-case-study","tag-laravel","tag-legacy","tag-migration","tag-php","tag-web-development"],"_links":{"self":[{"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/posts\/64","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/comments?post=64"}],"version-history":[{"count":1,"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/posts\/64\/revisions"}],"predecessor-version":[{"id":66,"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/posts\/64\/revisions\/66"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/media\/65"}],"wp:attachment":[{"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/media?parent=64"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/categories?post=64"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/tags?post=64"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}