Forge to Laravel Cloud Migration: Pinkary Playbook | Mohamed Said       [Skip to content](#main)  [ ![](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png) Mohamed SaidLaravel Backend Engineer ](https://www.msaied.com/public) - [Home](https://www.msaied.com/public)
- [Projects](https://www.msaied.com/public/projects)
- [Articles](https://www.msaied.com/public/articles)
- [Certificates](https://www.msaied.com/public/certificates)
- [About](https://www.msaied.com/public#about)

           [  Contact](https://www.msaied.com/public#contact) Menu 

Menu
----

Close 

 - [HomeStart here](https://www.msaied.com/public)
- [ProjectsCase studies](https://www.msaied.com/public/projects)
- [ArticlesEngineering notes](https://www.msaied.com/public/articles)
- [CertificatesCredentials](https://www.msaied.com/public/certificates)
- [AboutHow I work](https://www.msaied.com/public#about)
- [ContactGet in touch](https://www.msaied.com/public#contact)

  [Start a conversation](https://www.msaied.com/public#contact) [WhatsApp](https://wa.me/201094619204) [Email](mailto:hello@msaied.com) 

 1. [Home](https://www.msaied.com/public)
2. /
3. [Articles](https://www.msaied.com/public/articles)
4. /
5. [Laravel](https://www.msaied.com/public/articles?category=laravel)
6. /
7. Migrating Pinkary from Laravel Forge to Laravel Cloud: An Engineering Playbook

   [Laravel](https://www.msaied.com/public/articles?category=laravel) 

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

 A code-backed walkthrough of the real issues encountered moving Pinkary from Laravel Forge to Laravel Cloud: SQLite-to-MySQL schema fixes, nondeterministic query rewrites, object-storage assumptions, and purpose-built Artisan migration commands.

 ![](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp) [Mohamed Said](https://www.msaied.com/public#person) Published 30 Sep 2026 · Updated 1 Oct 2026 · 5 min read

ShareCopy linkCopied

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

  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)

 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*

- [Laravel Cloud](https://www.msaied.com/public/articles?search=Laravel%20Cloud)
- [Laravel Forge](https://www.msaied.com/public/articles?search=Laravel%20Forge)
- [MySQL](https://www.msaied.com/public/articles?search=MySQL)
- [SQLite](https://www.msaied.com/public/articles?search=SQLite)
- [Migration](https://www.msaied.com/public/articles?search=Migration)
- [Artisan](https://www.msaied.com/public/articles?search=Artisan)

 Frequently asked questions 
---------------------------

  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.

   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.

   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.

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

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

[About](https://www.msaied.com/public#about) [GitHub ↗](https://github.com/EG-Mohamed) [LinkedIn ↗](https://www.linkedin.com/in/msaiedm/) [WhatsApp ↗](https://wa.me/201094619204) [Email Address ↗](mailto:hello@msaied.com) [My CV ↗](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)  

   [Previous articlePostgreSQL Recursive CTEs in Laravel: Hierarchical Data Without the ORM Gymnastics](https://www.msaied.com/public/articles/postgresql-recursive-ctes-in-laravel-hierarchical-data-without-the-orm-gymnastics) [Next articleLaravel AI SDK and Laravel MCP Security Fixes: Update Now](https://www.msaied.com/public/articles/laravel-ai-sdk-and-laravel-mcp-security-fixes-update-now)  

   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)

 ###  Have a technical challenge?

 Tell me what you’re building. I reply within two working days.

[Start a conversation](https://www.msaied.com/public#contact) 

   Related articles
-----------------

 [ ![](https://cdn.msaied.com/740/cce86edc21eddcbdd2f2454fadaf9c70.png)  · 3 min read### The Pipeline Pattern in Laravel: Custom Pipelines Beyond Middleware

5 Oct 2026 ](https://www.msaied.com/public/articles/the-pipeline-pattern-in-laravel-custom-pipelines-beyond-middleware-1) [ ![](https://cdn.msaied.com/739/2d6897fdcdcf090613f96f72a64b8a78.png)  · 4 min read### MySQL Full-Text Search in Laravel: Indexes, Relevance Scoring, and Boolean Mode

4 Oct 2026 ](https://www.msaied.com/public/articles/mysql-full-text-search-in-laravel-indexes-relevance-scoring-and-boolean-mode) [ ![](https://cdn.msaied.com/738/073696a3fefe18bec825beec5ac658f5.png)  · 4 min read### Laravel Queue Rate-Limited Middleware: Throttling Jobs Without Losing Work

4 Oct 2026 ](https://www.msaied.com/public/articles/laravel-queue-rate-limited-middleware-throttling-jobs-without-losing-work) 

  Have a technical challenge?
----------------------------

Tell me what you’re building. I reply within two working days.

 [Discuss your project ↗](https://www.msaied.com/public#contact) 

  © 2026 Mohamed Said · Built with Laravel, meant to last.Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

 - [Home](https://www.msaied.com/public)
- [Articles](https://www.msaied.com/public/articles)
- [Certificates](https://www.msaied.com/public/certificates)
- [GitHub](https://github.com/EG-Mohamed)
- [LinkedIn](https://www.linkedin.com/in/msaiedm/)
- [WhatsApp](https://wa.me/201094619204)
- [Email Address](mailto:hello@msaied.com)
- [My CV](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)
- [Sitemap](https://www.msaied.com/public/sitemap.xml)
