Migrating Pinkary from Laravel Forge to Laravel Cloud: An engineering playbook

Pinkary's move from Laravel Forge to Laravel Cloud exposed SQLite and MySQL differences, query assumptions, and local-storage coupling. Here is the code-backed migration playbook.


Pinkary had been quietly storing string UUIDs in integer-affinity columns for over a year. The application worked. Tests passed. Users were happy. All good.

The problem only surfaced when we migrated the database off SQLite.

That is the thing about moving an application to a new environment: the environment exposes every shortcut the old one let you get away with.

I am a core maintainer on Pinkary, and in July 2026 we moved from Laravel Forge to Laravel Cloud, rehearsing the migration live on stream days before doing the final cutover offline at 1 a.m. What was billed as a quick task turned into more than five hours of live streams across two attempts and one abandoned strategy.

We had to fix schema differences, rewrite thread queries, and debug some weird image-processing behavior. The application changes are all in Pinkary's move-to-Cloud pull request and its final merge commit. If you are preparing to migrate an existing Forge app to Laravel Cloud, here are the actual technical issues we faced and how we solved them.

About Pinkary

Pinkary was built by someone who is definitely not a stranger to Laravel, Nuno Maduro. It is an open-source social link-in-bio platform built on Laravel and Livewire, Laravel's full-stack UI toolkit.

It launched in February 2024 on a single DigitalOcean droplet managed by Laravel Forge, backed by a local SQLite file and local file storage. It was fast, cozy, and easy to manage.

Nuno made the infrastructure calls, and he had already moved every other side project he owns onto Laravel Cloud, which launched in February 2025. Pinkary was the last holdout.

I took on this migration on my YouTube channel to make the process reproducible for other Laravel builders. Traffic at the time was small, which is why we could run the cutover after 1 a.m. without a formal maintenance window. Small scale is also why every problem in this post is a code-shape problem, not a load problem.

This was a pragmatic cutover for a small application, not a general zero-downtime migration design. If writes can continue during a database copy, you need a consistent source snapshot, a short write freeze, or a verified delta-sync plan.

Why we moved from Laravel Forge to Cloud

We had wanted to move Pinkary to Laravel Cloud from the beginning. We tried during the early access phase, and there was too much to change at the time, so it stalled.

Forge was not the problem. It gave us the control we needed to run Pinkary on our own server. The pull toward Cloud was different: Nuno wanted every side project on one platform, with the application runtime and managed resources handled together.

Once we committed to that move, Pinkary's infrastructure made two changes unavoidable. Laravel Cloud application instances do not provide permanent local storage across deployments and restarts, so a local SQLite file and a local uploads directory could not remain the system of record.

Moving to Cloud meant swapping in a managed MySQL database and an S3-compatible object-storage bucket. Cloud injects the resource credentials into the application environment; our code still had to stop assuming that the database was SQLite and the default disk was local.

1. SQLite to MySQL exposed compatibility problems

SQLite is great, but it is deliberately flexible about types. MySQL gave our migrations and queries a different set of rules. When we ran Pinkary against the destination engine, the mismatches surfaced immediately.

Hashtag identity was case-sensitive, while search was not

The original migration did two separate things. It created a normal unique index on name, which preserved case-sensitive hashtag identity, and then added a separate NOCASE index for lookups. That meant Laravel and laravel could remain distinct values even though autocomplete could find them without matching case.

Our first MySQL attempt risked changing that behavior. A reviewer caught it in PR #765, so the merged code deliberately used utf8mb4_bin for the unique column and moved case folding into the autocomplete query.

You can see both changes in the hashtag migration and the autocomplete query. The lesson was not "use a case-insensitive collation." It was to decide separately how identity and search should treat case, then encode both decisions explicitly.

The foreign ID UUID bug was in parent_id and root_id

Pinkary's questions.id is a UUID. Two later migrations added self-referencing parent_id and root_id columns with foreignId(), which creates unsigned big-integer columns. SQLite's flexible typing let UUID strings pass through those columns. MySQL did not.

The fix was to declare what those values actually are and add the self-referencing constraints:

The same change was made for root_id; see the parent ID migration and the root ID migration.

One subtle point: foreignIdFor(Question::class) was not the general problem. As Laravel's Blueprint implementation shows, it can infer a UUID column from a model that uses UUID keys. The incorrect declarations here were the explicit foreignId('parent_id') and foreignId('root_id') calls.

If you take one thing from this section, take this: run your migrations and representative queries against the database engine you intend to use. This was not simply a contest between a "loose" database and a "strict" one. SQLite and MySQL have different type, collation, grouping, and function semantics, and all of them can matter.

2. MySQL changed query semantics and exposed nondeterministic tests

Once the schema landed, the test suite started failing. There were several causes, not one: MySQL's grouping rules rejected a query shape SQLite had accepted, timestamp ties made implicit ordering assumptions visible, and physical column order changed the order of model attributes.

Rewriting thread queries with ROW_NUMBER() and joinSub()

Pinkary does not have an answers table. An answer is stored on a question row, and a conversation thread is linked with root_id and parent_id.

The old feed queries selected question columns, grouped rows by IFNULL(root_id, id), and ordered each group by the maximum updated_at value:

Under MySQL's default ONLY_FULL_GROUP_BY behavior, selecting id, root_id, and parent_id without grouping or aggregating them is not valid for this expression. More importantly, even where SQLite accepted the query, it did not state which row from each thread should supply those non-grouped values.

The replacement ranks every row inside its thread and then joins the winning row back into the outer query:

That query says what we actually mean: partition rows by thread, pick the latest one, use id as a deterministic tie-breaker, and return that specific row. The full implementation is in RecentQuestionsFeed, with the same pattern used in the following feed and profile question list.

Timestamp ties required explicit ordering

Some tests assumed that records created one after another would always receive different timestamps. They did not. The relevant columns had second-level precision, so multiple factories could share the same created_at or updated_at value. That was not evidence that MySQL was "too fast"; it was a timestamp-precision and ordering assumption.

We fixed this in two places. Production queries gained a stable secondary order such as id DESC, and tests that were specifically exercising time order used Laravel's clock helpers:

The migration also added a regression test for two rows in the same thread with identical timestamps. You can see both approaches in the feed tests and the secondary ID order for likes.

after() changed column order, not row order

MySQL applies Laravel's after() column modifier when altering a table. SQLite ignored it in our old test setup. That changed the physical order of columns returned by SELECT *, which in turn changed the key order of Eloquent's array representation.

The tests were checking the order of array_keys($model->toArray()), even though consumers only depended on the keys being present. We changed those expectations from an exact ordered array to a presence check plus a count:

That exact change appears across the model tests in the migration commit. after() did not reorder API records; it exposed tests that had mistaken storage layout for a public contract.

3. Object storage exposed local-filesystem assumptions

Moving to an S3-compatible bucket was straightforward at the infrastructure layer. Once the bucket was attached in Laravel Cloud, its credentials were available to the application. The code still contained assumptions that only worked with a local public disk.

Hardcoded disk names bypassed the configured default

When Pinkary was built, calls to Storage::disk('public') were scattered through the codebase. That forced Laravel to keep using the local public disk even after the environment's default disk changed.

The fix was to use the configured default disk wherever the code did not genuinely require a particular adapter:

That change appears throughout the Cloud migration commit, including avatar URLs, post images, cleanup jobs, Livewire uploads, and tests. Laravel then resolves the disk from FILESYSTEM_DISK instead of from a string buried in application code.

File existence checks became network calls

Pinkary parses Markdown posts into HTML on the backend. With the local disk, it checked whether every referenced image existed before emitting an <img> tag. On object storage, each exists() call became a remote request.

The fix was to stop making storage availability part of Markdown parsing. The parser now builds the object URL and lets the browser replace an image that fails to load:

The before-and-after implementation is in ImageProviderParsable. The broader lesson is simple: a filesystem abstraction makes the API portable, but it does not make remote I/O free.

Avatars and post images needed different fixes

Two image paths had become tangled in our explanation, but the code shows that they were separate.

For avatars, the job moved from GD and a local-path mutation to Intervention Image with the Imagick driver. It now reads the source, produces a PNG file pointer, and writes those encoded bytes through Laravel's storage API:

That implementation is in UpdateUserAvatar. It no longer relies on a local path that the image library can overwrite in place.

Post images already used Imagick directly. The memorable failure was an animated GIF of about 5 MB that grew to roughly 35 MB after every frame was decoded, resized, and written again. We stopped optimizing GIFs and stored them as uploaded. Other formats are resized, encoded to a stream, and then written to the configured disk:

The real post-upload path is in the Livewire Create component. Reading bytes from storage and calling save() without a destination would only mutate an in-memory image; it would not persist the result back to S3. Encoding to a stream and passing that stream to Storage::put() is the step that makes the remote write real.

4. Purpose-built Artisan commands beat our dump-conversion path

When it came time to migrate the production data, our first SQL dump-and-conversion path gave us a headache. UUID-like values were transformed incorrectly, and serialized cache values did not survive the conversion intact. That was a problem with our conversion path, not with SQLite's .dump command in general.

We scrapped that approach and wrote one-off Artisan commands that connected to both databases and copied the application data directly. The database command was introduced in PR #836 and is easiest to inspect in its pre-cutover source.

It did more than a bare chunk() loop:

  • verified that the source and target connections were reachable;

  • optionally ran migrations or rebuilt the target schema;

  • checked that source and target tables had matching columns;

  • refused to import into a target that already contained application data;

  • copied rows in stable chunks inside a target-side transaction;

  • reset MySQL auto-increment values; and

  • printed source and target row counts for every migrated table.

The core copy still stayed pleasantly small:

The command contained a relationship-verification method, but the call to it was commented out before cutover. Row-count parity is useful, but it is not the same as verifying every relationship, and a target transaction does not freeze the SQLite source. For a larger or busier system, I would run the copy from a verified snapshot or stop writes briefly, then validate relationships as well as counts before switching traffic.

The cache table did not contain the durable view counts

We did copy cache and cache_locks, but not because Pinkary stored its only copy of view counts there. Durable counts lived in the views columns on users and questions.

The cache held short-lived de-duplication keys: arrays of IDs a user or session had viewed in the previous 120 minutes. The IncrementViews job implementation checked those arrays before incrementing the database columns. Preserving cache continuity could prevent an immediate post-cutover double count, but clearing it would not erase the durable totals. The lock rows were coordination state, not business data.

Cache can help protect a durable write, but it should not be the only durable record.

The file copy streamed and verified every object

We used the same purpose-built approach for uploads. The actual command read from Pinkary's public disk, limited itself to avatars and images, and streamed every file to the S3-compatible target instead of loading the whole object into memory.

It also checked both disks up front, skipped matching remote files unless --overwrite was passed, and treated a size mismatch as an error. The complete implementation is in MigrateFilesToS3Command.

Credits

This migration was not a solo effort. I want to give a massive shoutout to Vikash Kapadiya and Bhushan Gaikwad, who helped me a lot during this journey. They acted as my "third eye" during the migration, joining me on live streams, catching bugs, and helping figure out these solutions in real time. Open source is a team effort, and their help was invaluable.

What changed after the move

Pinkary felt faster on Laravel Cloud than it did on the old deployment. We also changed the database and moved uploads to object storage at the same time, and we did not run a controlled before-and-after benchmark, so treat that as an observation rather than a measured result.

What we can say with confidence is that Pinkary now runs on Laravel Cloud with managed MySQL and object storage, and the move forced us to remove assumptions that had accumulated around SQLite and a local filesystem.

Preparing your application for a new environment is where the real engineering happens. Run migrations and representative queries against the destination database. Make ordering deterministic. Treat remote storage calls as network I/O. Encode image output before writing it to object storage. And when generic export tools fight the shape of your application, a small, purpose-built Artisan command can be exactly the right tool.


Sign up for Laravel Cloud and see what assumptions your current environment has been letting you carry.


Laravel is the most productive way to
 build, deploy, and monitor software.

By submitting this form, you agree to our terms. You can opt-out anytime.