Cutting a Laravel monorepo's CI from nine minutes to three without touching a test

A nine-minute pull request gate became 2m 44s, queue wait dropped from over a minute to two seconds, and about 70 engineer-hours a month came back, for roughly $150 a month in runners.

Industry: Media and events
Company size: 200-500 employees
Services: Platform engineering, CI/CD, developer experience


The Challenge

The client's main application is a Laravel monorepo with a busy engineering team: roughly 38 pipeline runs every weekday. The "Tests & Code Styling" job that gates every pull request took about nine minutes at the median, and the first 65 to 80 seconds of every run was dead time waiting for a hosted runner to appear. Engineers were context-switching away from pull requests and coming back later, which is where the real cost of slow CI hides.

The visible problem was the queue wait. The measurements showed that the queue was only the first of four problems.

Our Approach

We instrumented every stage of the job before changing anything, then attacked the stages in order of size.

  • In-cluster runners on the existing Kubernetes platform. GitHub's Actions Runner Controller replaced hosted runners, with a runner image that bakes in PHP 8.3, 8.4, and 8.5 with extensions, Node 22, opcache, the coverage driver, and database clients, so the per-run toolchain setup fast-paths instead of downloading.
  • A static-analysis cache that actually persists. Rector was taking 170 seconds per run because, under GitHub Actions, it silently selects an in-memory cache that is thrown away with the job. Forcing the on-disk cache onto a shared FSx mount took it to 7 seconds.
  • Test database on tmpfs and parallel tests. MySQL runs in memory with durability disabled for the job's lifetime, and the suite runs on eight parallel processes on a larger runner class. The test stage dropped from 104 seconds to 47.
  • A warm pool that works on Karpenter. The usual trick, low-priority "balloon" pods that get preempted by a real job, does not work when Karpenter provisions a new node faster than the scheduler can evict the balloon: the job lands on the cold node every time. We verified that with two balloons and then switched to real idle runners pre-registered with GitHub, scheduled for business hours and scaled to zero on weekends. Start latency went from 65 to 80 seconds down to 2.
  • A runner-fleet dashboard. Job latency against pool size, throughput by repository, and a demand heatmap by hour, so the pool size is a tuning decision with data rather than a guess.

Two months later a second pass moved the heaviest jobs onto 16-vCPU runners and cut the test stage again, from 347 seconds to 179, taking about 45 percent off wall time for the pipelines that had grown in the meantime.

The Results

9 min → 2m 44smedian run time, a 70 percent cut
65-80 s → 2 squeue wait before a job starts
~70 hrsengineer wait time reclaimed per month
~$150/mocost of the business-hours warm pool
  • Rector 170 s to 7 s by fixing one cache setting nobody had noticed
  • Test suite 104 s to 47 s with tmpfs and parallel processes
  • About $72k a year of reclaimed engineering time at the client's loaded salary assumption, against under $2k a year in runner cost. The pool pays for itself if it recovers seven percent of the dead time.
  • The run is now compute-bound: tests, database start, and code style are what's left, so the next improvements belong to the application team, not the platform

Key Takeaway

Most of a slow CI run is not tests. It is queueing, toolchain setup, and caches that quietly do nothing. Measure each stage first; the biggest wins here came from a cache flag and a scheduling fix, not from touching a single test.

Related guide: Why your CI pipeline is slow, and why it is probably not the tests


Is your team waiting on CI?

We'll profile your slowest pipeline stage by stage and show you where the minutes are going.

Schedule a call