The code quality stack behind every Laracraft package
The tools we refuse to ship Laravel code without: PestPHP, PHPStan, Rector, Pint, and a GitHub Actions matrix that runs our packages against every supported Laravel and PHP version.
At Laracraft we ship two kinds of code: public Laravel packages under laracraft-tech and the production apps that keep our clients (and ourselves) in business. The one thing both have in common is the quality gate every change runs through before it gets merged.
This post is not a tutorial on "how to set up your pipeline". It is a short tour through the tools we actually rely on day to day, and a few opinions on why we rely on them. If you maintain Laravel code that other humans depend on, a lot of this will feel familiar. If you don't, borrow whatever is useful.
The lineup:
- PestPHP for tests
- PHPStan / Larastan for static analysis
- Rector for automated refactoring
- Laravel Pint for code style
- GitHub Actions as the glue
- Matrix builds so our packages keep working across every supported PHP and Laravel version
#PestPHP, because tests should read like specifications
We default to PestPHP 3 for every new project and migrate older suites over when we touch them. Pest sits on top of PHPUnit, so nothing is lost, but the ergonomics are a different league. Expectations read like sentences, it() blocks document intent, and datasets let us blast a single test across dozens of fixture rows without boilerplate.
1it('filters users by active state', function (bool $active, int $expected) {
2 User::factory()->count(3)->create(['active' => true]);
3 User::factory()->count(2)->create(['active' => false]);
4
5 expect(User::query()->where('active', $active)->count())->toBe($expected);
6})->with([[true, 3], [false, 2]]);
Arch tests are the underrated part: we use them to keep layers honest. No Eloquent queries in controllers, no Facades in domain services, strict typing across the board, and so on. It is one config block and a permanent safety net.
#PHPStan at level 6, non negotiable
Tests tell us that the happy path works. PHPStan tells us that the unhappy paths even exist. For our packages we run level: 6 with Larastan on top, which teaches PHPStan about Eloquent return types, helper signatures, and the usual Laravel magic. Level 6 is the sweet spot for us: it catches the bugs that matter (missing return types, wrong array shapes, null safety) without drowning contributors in false positives from the stricter levels.
The payoff is mundane and constant: typos in property names, wrong array shapes, methods returning null on a branch nobody noticed, redundant conditions, dead code. Reviewers should review ideas, not spend their afternoon spotting a missing ? in a return type. Static analysis handles the boring half of review so humans can focus on the interesting half.
PHPStan runs on every push and every pull request. Red build, no merge. We don't negotiate with warnings.
#Rector, the refactor robot that never gets bored
Rector is the tool that people discover late and then wonder how they ever lived without. We use it for three very different jobs:
- PHP version upgrades. When we move a project from PHP 8.2 to 8.3 or 8.4, Rector rewrites the obvious things (constructor promotion, readonly properties, modern match expressions, typed constants) in minutes, not days.
- Laravel upgrades. Whenever Laravel ships a new major, the Rector Laravel sets handle the mechanical parts of the upgrade diff for us. We still read every change, but we are reviewing a patch instead of typing it.
- Continuous cleanup. Type coverage sets, dead code removal, early return refactors, and code quality sets run in a weekly job. Small, boring, steady improvements with no drama.
In CI Rector runs with --dry-run. If it finds something, the build fails and we commit the fix locally. We never let CI auto-push refactors into main, but we also never rewrite by hand what a deterministic tool can rewrite for us. This is the biggest single reason our older packages still feel fresh.
#Laravel Pint, because code style is not an opinion worth defending
Laravel Pint is the last one in the chain and the simplest. One config, the laravel preset plus a few opinionated overrides, and every file across every repository looks the same. Pint runs as a pre commit hook locally and as pint --test in CI.
We do not hold style discussions in pull requests anymore. There is nothing to discuss. Pint has the final word, and that means reviewers spend their attention on the code that actually needs a human.
#GitHub Actions, the clamp that holds it all together
Everything above is worth nothing if it only runs on the machine of whoever remembers to run it. Our baseline GitHub Actions workflow has four jobs that must be green before a branch can merge:
pestruns the full test suitephpstanruns static analysis at the configured levelrector --dry-runverifies there is nothing left to modernizepint --testverifies the diff is already formatted
A minimal Pest job looks like this:
1jobs:
2 pest:
3 runs-on: ubuntu-latest
4 steps:
5 - uses: actions/checkout@v4
6 - uses: shivammathur/setup-php@v2
7 with:
8 php-version: 8.3
9 coverage: none
10 - name: Install dependencies
11 run: composer install --no-interaction --prefer-dist
12 - name: Run Pest
13 run: vendor/bin/pest --compact
Nothing fancy, and that is the point. Boring CI is a feature. You can see the real thing on any laracraft-tech/* repo, for example laravel-useful-additions.
#Matrix builds, the real superpower for package maintainers
This is the part that separates a hobby package from one you can depend on in production. A public Laravel package today is expected to support several Laravel versions (for us typically L10, L11, L12) on several PHP versions (8.2, 8.3, 8.4). The only honest way to claim that support is to actually run the test suite against every combination, every time.
We do that with a strategy.matrix in our workflows:
1strategy:
2 fail-fast: false
3 matrix:
4 php: ['8.2', '8.3', '8.4']
5 laravel: ['10.*', '11.*', '12.*']
6 exclude:
7 - php: '8.2'
8 laravel: '12.*'
9steps:
10 - uses: actions/checkout@v4
11 - uses: shivammathur/setup-php@v2
12 with:
13 php-version: ${{ matrix.php }}
14 - name: Require Laravel ${{ matrix.laravel }}
15 run: |
16 composer require "laravel/framework:${{ matrix.laravel }}" \
17 --no-interaction --no-update
18 composer update --prefer-dist --no-interaction
19 - run: vendor/bin/pest --compact
Two things are doing the work here. The matrix block fans the job out into one run per combination. The composer require ... --no-update trick pins the Laravel version for that specific cell, so every matrix entry actually installs and tests against the version it claims.
That is how a single push produces a wall of green checkmarks across nine (or more) PHP+Laravel combinations, and how we can say with a straight face that our packages support what the README says they support. You can see this exact setup live in laravel-useful-additions, laravel-date-scopes, and laravel-xhprof.
#Why we bother
None of these tools is exotic. The difference is that we treat them as default on, not opt in. Pest + PHPStan + Rector + Pint + a real matrix build is the reason our packages do not slowly rot into "works on my machine" while new Laravel versions ship. It is also the reason our client projects survive handovers, version bumps, and the occasional frantic late night bug fix without collapsing.
If you are starting a new Laravel package or cleaning up an old one, don't reinvent the workflow. Open any laracraft-tech/* repository, steal the .github/workflows/*.yml, adjust the matrix to your supported versions, and build something you can actually stand behind.
That is the whole secret. Good tools, turned on by default, running on every push.