Forge to Laravel Cloud Migration: Pinkary Playbook | Mohamed Said        [  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png)   Mohamed Said Laravel Backend Engineer  ](https://msaied.com) [ Home ](https://msaied.com) [ Projects ](https://msaied.com/projects) [ Articles  ](https://msaied.com/articles) [ Certificates ](https://msaied.com/certificates) [ Contact ](https://msaied.com#contact-section) 

       [  ](https://github.com/EG-Mohamed)       

 [ Home ](https://msaied.com) [ Projects ](https://msaied.com/projects) [ Articles ](https://msaied.com/articles) [ Certificates ](https://msaied.com/certificates) [ Contact ](https://msaied.com#contact-section) 

  [ home ](https://msaied.com)    [ articles ](https://msaied.com/articles)    Migrating Pinkary from Laravel Forge to Laravel Cloud: An Engineering Playbook        On this page       1. [  Overview ](#overview)
2. [  1. SQLite to MySQL Exposed Schema Incompatibilities ](#1-sqlite-to-mysql-exposed-schema-incompatibilities)
3. [  Case-sensitive hashtag identity vs. case-insensitive search ](#case-sensitive-hashtag-identity-vs-case-insensitive-search)
4. [  foreignId() columns storing UUID strings ](#codeforeignidcode-columns-storing-uuid-strings)
5. [  2. MySQL Query Semantics Broke Nondeterministic Tests ](#2-mysql-query-semantics-broke-nondeterministic-tests)
6. [  Rewriting thread queries with ROW\_NUMBER() ](#rewriting-thread-queries-with-coderow-numbercode)
7. [  Timestamp ties and deterministic ordering ](#timestamp-ties-and-deterministic-ordering)
8. [  after() changed column order, not row order ](#codeaftercode-changed-column-order-not-row-order)
9. [  3. Object Storage Exposed Local-Filesystem Assumptions ](#3-object-storage-exposed-local-filesystem-assumptions)
10. [  Hardcoded Storage::disk('public') calls ](#hardcoded-codestoragediskpubliccode-calls)
11. [  File-existence checks became network calls ](#file-existence-checks-became-network-calls)
12. [  Image processing for avatars and post images ](#image-processing-for-avatars-and-post-images)
13. [  4. Purpose-Built Artisan Commands Beat Generic SQL Dumps ](#4-purpose-built-artisan-commands-beat-generic-sql-dumps)
14. [  Key Takeaways ](#key-takeaways)

  ![Migrating Pinkary from Laravel Forge to Laravel Cloud: An Engineering Playbook](https://cdn.msaied.com/723/6f9c9010ccb06581b52bcfab81e47dd9.png)

 [  Laravel ](https://msaied.com/articles?category=laravel)  #Laravel Cloud   #Laravel Forge   #MySQL   #SQLite   #Migration   #Artisan  

 Migrating Pinkary from Laravel Forge to Laravel Cloud: An Engineering Playbook 
================================================================================

     30 Sep 2026      5 min read    ![Mohamed Said](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp)  Mohamed Said  

       Table of contents

  14 sections  

1. [  01   Overview  ](#overview)
2. [  02   1. SQLite to MySQL Exposed Schema Incompatibilities  ](#1-sqlite-to-mysql-exposed-schema-incompatibilities)
3. [  03   Case-sensitive hashtag identity vs. case-insensitive search  ](#case-sensitive-hashtag-identity-vs-case-insensitive-search)
4. [  04   foreignId() columns storing UUID strings  ](#codeforeignidcode-columns-storing-uuid-strings)
5. [  05   2. MySQL Query Semantics Broke Nondeterministic Tests  ](#2-mysql-query-semantics-broke-nondeterministic-tests)
6. [  06   Rewriting thread queries with ROW\_NUMBER()  ](#rewriting-thread-queries-with-coderow-numbercode)
7. [  07   Timestamp ties and deterministic ordering  ](#timestamp-ties-and-deterministic-ordering)
8. [  08   after() changed column order, not row order  ](#codeaftercode-changed-column-order-not-row-order)
9. [  09   3. Object Storage Exposed Local-Filesystem Assumptions  ](#3-object-storage-exposed-local-filesystem-assumptions)
10. [  10   Hardcoded Storage::disk('public') calls  ](#hardcoded-codestoragediskpubliccode-calls)
11. [  11   File-existence checks became network calls  ](#file-existence-checks-became-network-calls)
12. [  12   Image processing for avatars and post images  ](#image-processing-for-avatars-and-post-images)
13. [  13   4. Purpose-Built Artisan Commands Beat Generic SQL Dumps  ](#4-purpose-built-artisan-commands-beat-generic-sql-dumps)
14. [  14   Key Takeaways  ](#key-takeaways)

       Overview
--------

In July 2026, the Pinkary team moved their open-source social link-in-bio platform from a single DigitalOcean droplet managed by [Laravel Forge](https://forge.laravel.com) to [Laravel Cloud](https://laravel.com/cloud). What looked like a quick task stretched into five-plus hours of live-streamed work across two attempts. Every problem that surfaced was a **code-shape problem**, not a load problem — and each one is reproducible in any Laravel app that grew up on SQLite and local storage.

All changes live in [Pinkary's migration pull request](https://github.com/pinkary-project/pinkary.com/pull/765) and its [final merge commit](https://github.com/pinkary-project/pinkary.com/commit/7f00054a3be7ac6de76b09521c930ded57567b94).

---

1. SQLite to MySQL Exposed Schema Incompatibilities
---------------------------------------------------

### Case-sensitive hashtag identity vs. case-insensitive search

The original SQLite migration created a unique index on `name` plus a separate `NOCASE` index for autocomplete. On MySQL, the team used `utf8mb4_bin` collation for the unique column to preserve case-sensitive identity, then moved case folding into the autocomplete query itself.

> **Lesson:** Decide separately how *identity* and *search* should treat case, then encode both decisions explicitly in your schema.

### `foreignId()` columns storing UUID strings

`questions.id` is a UUID. Two later migrations added `parent_id` and `root_id` with `foreignId()`, which creates unsigned big-integer columns. SQLite silently accepted UUID strings in those columns; MySQL rejected them immediately.

The fix was to replace `foreignId()` with `foreignUuid()` and add proper self-referencing foreign-key constraints. Note that `foreignIdFor(Question::class)` would have inferred the UUID type automatically — the bug was in the explicit `foreignId('parent_id')` calls.

---

2. MySQL Query Semantics Broke Nondeterministic Tests
-----------------------------------------------------

### Rewriting thread queries with `ROW_NUMBER()`

Pinkary stores answers on question rows linked by `root_id` and `parent_id`. The old feed query grouped by `IFNULL(root_id, id)` and ordered by `MAX(updated_at)`, selecting non-aggregated columns — valid in SQLite, rejected by MySQL's `ONLY_FULL_GROUP_BY`.

The replacement uses a window function to rank rows within each thread, then joins the top-ranked row back into the outer query:

```php
$ranked = DB::table('questions')
    ->select('*', DB::raw(
        'ROW_NUMBER() OVER (
            PARTITION BY IFNULL(root_id, id)
            ORDER BY updated_at DESC, id DESC
        ) AS rn'
    ));

$query->joinSub($ranked, 'ranked', fn ($join) =>
    $join->on('questions.id', '=', 'ranked.id')
)->where('ranked.rn', 1);

```

### Timestamp ties and deterministic ordering

Second-level timestamp precision meant multiple factory-created rows could share the same `created_at`. Production queries gained a stable secondary sort (`id DESC`), and time-sensitive tests used Laravel's clock helpers to advance time between records.

### `after()` changed column order, not row order

MySQL applies Laravel's `after()` column modifier; SQLite ignored it. This changed the key order of `$model->toArray()`, breaking tests that asserted an exact `array_keys()` sequence. The fix was to assert key *presence* and count rather than order.

---

3. Object Storage Exposed Local-Filesystem Assumptions
------------------------------------------------------

### Hardcoded `Storage::disk('public')` calls

Replacing `Storage::disk('public')` with `Storage::disk()` (no argument) lets Laravel resolve the disk from the `FILESYSTEM_DISK` environment variable, which Laravel Cloud injects automatically when a bucket is attached.

### File-existence checks became network calls

The Markdown parser checked `Storage::exists()` before emitting each `` tag. On S3, every check is a remote request. The fix: build the object URL unconditionally and let the browser handle a 404 with an `onerror` handler.

### Image processing for avatars and post images

- **Avatars** moved from GD with local-path mutation to Intervention Image (Imagick driver), encoding to a PNG stream and writing via `Storage::put()`.
- **Post images** already used Imagick. A 5 MB animated GIF ballooned to ~35 MB after per-frame processing, so GIFs are now stored as uploaded. Other formats are resized, encoded to a stream, and written to the configured disk.

---

4. Purpose-Built Artisan Commands Beat Generic SQL Dumps
--------------------------------------------------------

A SQL dump-and-conversion path mangled UUID values and broke serialized cache entries. The team replaced it with two one-off Artisan commands:

- **`MigrateSqliteToMysqlCommand`** — verified both connections, checked column parity, refused to import into a non-empty target, copied rows in chunked transactions, and reset MySQL auto-increment counters.
- **`MigrateFilesToS3Command`** — streamed files from the `public` disk to S3, skipped already-matching remote files, and treated a size mismatch as an error.

The core copy loop stayed simple:

```php
DB::connection('source')
    ->table($table)
    ->orderBy('id')
    ->chunk(500, function ($rows) use ($table) {
        DB::connection('target')
            ->table($table)
            ->insert($rows->map(fn ($r) => (array) $r)->all());
    });

```

---

Key Takeaways
-------------

- Run migrations and representative queries against the **destination** database engine before cutover.
- Use `foreignUuid()` (or `foreignIdFor()`) for UUID foreign keys — never `foreignId()`.
- Make all query ordering deterministic with a stable secondary sort column.
- Replace `Storage::disk('public')` with `Storage::disk()` to respect the environment's configured default.
- Encode images to a stream before calling `Storage::put()` — `save()` without a path only mutates memory.
- When generic export tools fight your data shape, a small purpose-built Artisan command is often the right tool.

---

*Source: [Migrating Pinkary from Laravel Forge to Laravel Cloud](https://laravel.com/blog/migrating-pinkary-from-laravel-forge-to-laravel-cloud) — Laravel Blog, Sep 30, 2026*

 Found this useful?

          [  ](https://twitter.com/intent/tweet?url=https%3A%2F%2Fmsaied.com%2Farticles%2Fmigrating-pinkary-from-laravel-forge-to-laravel-cloud-an-engineering-playbook&text=Migrating+Pinkary+from+Laravel+Forge+to+Laravel+Cloud%3A+An+Engineering+Playbook) [  ](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fmsaied.com%2Farticles%2Fmigrating-pinkary-from-laravel-forge-to-laravel-cloud-an-engineering-playbook) 

 Frequently Asked Questions 
----------------------------

  3 questions  

     Q01  Why did `foreignId()` columns cause errors when migrating from SQLite to MySQL?        `foreignId()` creates an unsigned big-integer column. SQLite's flexible typing silently accepted UUID strings in those columns, but MySQL enforces strict type rules and rejected them. The fix is to use `foreignUuid()` — or `foreignIdFor(ModelClass::class)`, which infers the correct column type from the model — whenever the referenced primary key is a UUID. 

      Q02  How did Pinkary handle MySQL's ONLY\_FULL\_GROUP\_BY restriction on thread feed queries?        The original query grouped rows by `IFNULL(root_id, id)` and selected non-aggregated columns, which SQLite permitted but MySQL's default `ONLY_FULL_GROUP_BY` mode rejects. The replacement uses a `ROW_NUMBER()` window function to rank rows within each thread partition, then joins only the top-ranked row back into the outer query, making the intent explicit and fully valid under MySQL. 

      Q03  What is the recommended approach for migrating files from local storage to S3 when moving to Laravel Cloud?        Rather than relying on generic sync tools, the Pinkary team wrote a purpose-built Artisan command that reads from the local `public` disk, streams each file to the S3-compatible target (avoiding loading entire objects into memory), skips files that already exist on the target with a matching size, and treats any size mismatch as an error. This approach is more reliable than dump-based methods when file metadata or binary content must be preserved exactly. 

  Continue reading

 More Articles 
---------------

 [ View all    ](https://msaied.com/articles) 

 [ ![Laravel AI SDK and Laravel MCP Security Fixes: Update Now](https://cdn.msaied.com/722/fbe8df92faa32e9b9cee984a18bd4411.png) security laravel-ai laravel-mcp 

### Laravel AI SDK and Laravel MCP Security Fixes: Update Now

Two security advisories were published for laravel/ai and laravel/mcp on September 30, 2026. Both are fixed—ru...

  ![Mohamed Said](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp)  Mohamed Said 

 30 Sep 2026     3 min read  

  Read    

 ](https://msaied.com/articles/laravel-ai-sdk-and-laravel-mcp-security-fixes-update-now) [ ![PostgreSQL Recursive CTEs in Laravel: Hierarchical Data Without the ORM Gymnastics](https://cdn.msaied.com/720/3e3d256d54dd4036099122e0b035e482.png) laravel postgresql eloquent 

### PostgreSQL Recursive CTEs in Laravel: Hierarchical Data Without the ORM Gymnastics

Recursive CTEs let PostgreSQL walk tree structures in a single query. Learn how to wire them into Laravel's qu...

  ![Mohamed Said](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp)  Mohamed Said 

 30 Sep 2026     2 min read  

  Read    

 ](https://msaied.com/articles/postgresql-recursive-ctes-in-laravel-hierarchical-data-without-the-orm-gymnastics) [ ![FrankenPHP, OPcache JIT, and Preloading: Maximising Laravel Throughput](https://cdn.msaied.com/719/2129727c31378ebd8778814d6764bac1.png) laravel frankenphp performance 

### FrankenPHP, OPcache JIT, and Preloading: Maximising Laravel Throughput

A practical deep-dive into running Laravel under FrankenPHP with OPcache JIT and preloading enabled — covering...

  ![Mohamed Said](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp)  Mohamed Said 

 30 Sep 2026     2 min read  

  Read    

 ](https://msaied.com/articles/frankenphp-opcache-jit-and-preloading-maximising-laravel-throughput-1) 

   [  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png)   Mohamed Said Laravel Backend Engineer  ](https://msaied.com)Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

Explore

- [Home](https://msaied.com)
- [Projects](https://msaied.com/projects)
- [Articles](https://msaied.com/articles)
- [Certificates](https://msaied.com/certificates)
- [Contact](https://msaied.com#contact-section)

Connect

- [   hello@msaied.com ](mailto:hello@msaied.com)
- [   +20 109 461 9204 ](tel:+201094619204)

© 2026 Mohamed Said. All rights reserved.

 [  ](https://github.com/EG-Mohamed) [  ](https://www.linkedin.com/in/msaiedm/) [  ](https://wa.me/201094619204) [  ](mailto:hello@msaied.com) [  ](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)
