One missing command, two days of reconciliation
A few years ago we inherited an e-commerce back office where releasing meant SSH-ing into the server and running git pull, composer install, migrate, then clearing caches by hand. During a rushed Friday-evening release the engineer skipped php artisan config:cache. The site kept reading stale payment gateway settings, and 27 payment callbacks failed over three hours. The technical fix took 40 minutes; manual reconciliation and support took two days. The postmortem conclusion was not that someone was careless. It was that when releasing is a sequence of commands held in memory, anyone drops a step on a tired night.
What we do: make deployment a button nobody can bypass
Nobody at ScriptWalker holds permission to deploy production by hand, and every change travels the same pipeline. For Laravel projects the trip from git push to live averages 11 minutes: roughly 7 minutes of CI and 4 minutes of deploy plus verification.
git push (feature branch)
→ GitHub Actions CI: Pint format check → Larastan static analysis → Pest tests → Playwright E2E (8 critical paths)
→ PR fully green + 1 reviewer approval
→ squash merge into main
→ Actions creates a version tag
→ calls the Laravel Forge deploy hook (build cloned into releases/, symlink switched)
→ migrate --force + artisan optimize
→ smoke test /health and 3 critical paths
→ create a Sentry release and associate commits
→ report to Slack #deploys; on failure the symlink points back automatically
Flutter runs a parallel track: the same tag triggers Codemagic to build iOS and Android, sign them, and ship to TestFlight and the Play internal testing track.
Why we skipped the more "complete" version
We deliberately left out three things the industry treats as standard.
- No Kubernetes, no real blue-green. Most of what we operate runs on one or two machines. Laravel Forge's zero-downtime deployment already gives a seamless switch using a
releases/directory and a symlink, and rollback is just pointing that symlink back. Running a K8s cluster to sound serious would eat a small team's capacity outright. - Eight E2E specs, not eighty. Our budget for the whole CI run is a hard 10 minutes. Past that, engineers stack PRs instead of merging and then land five at once, which is where the real risk lives. Playwright's own CI guidance points to sharding rather than endlessly lengthening a serial run.
- main goes straight to production, except payments and push. Any PR labelled
paymentorpush-notificationhits a GitHub Environments required-reviewer gate and will not deploy until a second person approves.
The trade-off is explicit: a very short pipeline with locks on the dangerous paths, instead of a thorough one nobody is willing to sit through.
How it actually runs: tools, config, files
Step 1 — catch it locally. A pre-commit hook runs only Pint and dart format, finishing in under two seconds, with no tests. Slow hooks are hooks people skip.
Step 2 — CI. The workflow file, excerpted:
name: ci
on:
pull_request:
push: { branches: [main] }
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: vendor
key: composer-${{ hashFiles('composer.lock') }}
- run: composer install --no-interaction --prefer-dist
- run: ./vendor/bin/pint --test
- run: ./vendor/bin/phpstan analyse --memory-limit=1G
- run: php artisan test --parallel
e2e:
needs: quality
runs-on: ubuntu-latest
steps:
- run: npx playwright test --shard=1/2
The concurrency block saves both money and time by cancelling older runs on the same branch, a behaviour documented in the GitHub Actions documentation.
Step 3 — deploy and verify. The tail of every Forge deploy script is fixed:
php artisan migrate --force
php artisan optimize
php artisan queue:restart
curl -fsS https://APP_URL/health || exit 1
sentry-cli releases new "$FORGE_SHA"
sentry-cli releases set-commits --auto "$FORGE_SHA"
artisan optimize is the direct answer to the step we once missed; the official Laravel deployment guide lists it as a command to run on every deployment. queue:restart makes workers pick up new code. Finally the build is pushed into Sentry Releases, so any later error maps back to the deployment that introduced it.
Step 4 — Flutter. Mobile follows the official Flutter continuous delivery guide, wrapping signing and upload in Fastlane, executed by Codemagic, with the version number taken from the tag.
What this costs us (the honest list)
- The CI bill is real. Across a dozen active repos we exceed the free Actions allowance every month, and GitHub's plans page is explicit that overage is billed per minute. We absorb it rather than passing it on.
- Fixing a typo also takes 11 minutes. There is no express lane, so "that heading is misspelled" still waits for the pipeline, and that is hard to explain.
- The pipeline itself needs feeding. Roughly 4 to 6 hours a quarter go to flaky E2E specs, action version bumps, and jobs broken by PHP or Flutter upgrades. None of those hours appear on a quote.
- New hires are slowed for two weeks. Larastan and Pint rules irritate anyone who has not internalised them; a first PR often goes red three or four times.
When you should not do this
- A small product still testing its premise. If it may live six weeks and serve under a hundred users, time spent building a pipeline is time not spent talking to users. For those we enable Forge quick deploy only.
- Client servers on an isolated internal network. Common in finance and healthcare. Actions cannot reach the deploy target, so you need self-hosted runners, and that cost difference belongs in the quote.
- A legacy project with zero automated tests. Building CI first only gives you a slow pipeline that checks nothing.
Why this matters if you are the client
- Predictable timing. "When will I see the change?" has a fixed answer of roughly a dozen minutes, not "when I get a free evening." Urgent fixes need no calendar negotiation.
- Rollback costs almost nothing. If a release feels wrong we point the symlink back and are usually restored within two minutes, with no meeting about whether to revert. Both sides get braver.
- You pay for features, not rework. Machines catch formatting and static analysis, so reviews average 15 minutes and none of it is about indentation.
If your team wants the same thing
- Set the time budget before choosing what to run. Agree CI may never exceed 10 minutes, then pick tests to fit. Do it the other way and the pipeline inflates until nobody waits.
- Your first move is scripting the deploy, not writing tests. Even if a human triggers it, turning the release into a file instead of a memory solves half the problem.
- Rehearse rollback until it is muscle memory. We practise one on staging every quarter. An unrehearsed rollback does not exist.
- Cache your dependencies. Using actions/cache against
composer.lockorpubspec.locktypically removes 30 to 40 percent of CI time. - Make failure loud. Failed deployments belong in a main Slack channel, not a quiet email. Silent failures compound into lost trust.
Where we have actually run this
Online ordering for a restaurant chain (Laravel + Flutter). Releases used to be manual FTP uploads with one or two files missed every time. After the pipeline: 140 deployments in six months, 2 automatic rollbacks, zero outages caused by deployment error.
A B2B SaaS admin platform. Two of the client's own engineers build alongside us, so we handed them the same workflow. The clearest change was in PR discussions: half used to be arguments about style, and now the conversation is entirely about business logic.
Frequently asked questions
Is CI/CD worth it for a team of two or three?
Yes, but keep the scope small. The fewer people you have, the less you can afford one late-night manual mistake. The minimum viable version is a CI run with lint plus critical-path tests and a one-command deploy script, about two days of work.
How long does adoption take and what does it cost?
For an existing Laravel project, going from nothing to a full pipeline takes us 3 to 5 working days, with test coverage usually consuming more than half. Ongoing cost is mostly CI minutes plus a Laravel Forge subscription, typically tens of dollars per month.
Can we do CI without automated deployment?
Yes, and for many teams that is the correct first step. Get every PR checked, build trust in the test suite, and only then flip on automatic deployment. The opposite order is considerably riskier.
Isn't running database migrations automatically dangerous?
It can be, so we hold two rules: destructive changes such as dropping a column are always split across two deployments, adding before removing; and a snapshot is taken before deploy. The Laravel migrations documentation likewise warns that --force in production means knowing exactly what will execute.
Will you hand the pipeline over to our own team?
Yes, and it is part of our default deliverable. The workflow files, deploy scripts and runbook all live in your repository, so you are not tied to us afterwards.
Next steps
If you are evaluating a Laravel or Flutter project and past experience has made going live feel unsafe, we will review your current repository and give you a concrete, ordered improvement plan at no charge. And if you are an engineer who turns repetitive work into scripts and cannot leave a red CI badge alone, we hire for that continuously; this pipeline was grown by exactly those people, one commit at a time.
- Email: [email protected]
- Phone: 0916-224-047
- LINE: @ufv9089p