网页 综合 更新时间 2026-09-30 16:24:00

Migrating Pinkary from Laravel Forge to Laravel Cloud: An engineering playbook | Laravel - The clean stack for Artisans and agents

自动蜘蛛 laravel.com 正文约 6000 字
Migrating Pinkary from Laravel Forge to Laravel Cloud: An engineering playbook | Laravel - The clean stack for Artisans and agents
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.
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.
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.
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.
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.
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.
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.
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_i
访问原文 返回搜索 https://laravel.com/blog/migrating-pinkary-from-laravel-forge-to-laravel-cloud
本页为82搜索生成的网页快照,内容来自站内索引;演示数据的原文链接仅作示例。