Testing & Code Quality β
Most PHP projects end up with the same pile of QA config: a phpunit.xml, a .php-cs-fixer.php, maybe a rector.php and a phpstan.neon, plus hand-written CI workflows to run them all. None of it is hard. It is just setup you have to get right in four different formats, and keep in sync forever.
Alchemy replaces that pile with one file. You describe what you want in alchemy.yml, and Alchemy handles the rest: tests with Pest or PHPUnit, code style with PHP CS Fixer, refactoring with Rector, static analysis with PHPStan, and CI pipelines for GitHub Actions, GitLab CI or CircleCI.
Alchemy works in any PHP project, not just Leaf. It detects Laravel, Symfony, Slim, Leaf or a plain composer setup and adapts. Coming from Laravel? There's a dedicated section on how Alchemy fits around Pint, Larastan and the rest of your existing setup.
How Alchemy works β
Before any config reference, here is the mental model. It is small:
alchemy.ymlis your QA policy. Each tool gets a section:tests,lint,refactor,analyse,actions. A section existing means "I want this"; a missing section means the tool never runs or installs.- Nothing installs until you use it. Requiring Alchemy adds nothing else to your dependency tree. Pest arrives the first time you run your tests, Rector the first time you refactor, and always at the newest version your PHP supports.
- Real config is generated per run, then discarded. When you run a command, Alchemy translates your yml into the tool's native config inside
.alchemy/, runs the tool, and throws the config away. Your project root is never written. Only engine caches stick around, and.alchemyis gitignored for you. - You can always leave.
alchemy ejectexports real config files and rewires your composer scripts to call the engines directly. Your tests were plain Pest or PHPUnit tests all along.
That's it. Everything below is detail on top of these four ideas.
Setting up β
Leaf CLI will ask if you want Alchemy when you create a new project. To add it to an existing project:
leaf install alchemy --devcomposer require leafs/alchemy --devThen initialize it:
./vendor/bin/alchemy initinit looks at your project before writing anything. It detects your framework, picks up the test engine you already use, and writes an alchemy.yml to match. If it finds existing tool configs (a phpunit.xml, a phpstan neon, a rector.php), it asks one question per file: port it into alchemy.yml, or keep it?
- Port translates the config into the yml, suites and rules and all. The original file is parked at
.alchemy/<file>.bak, so your project root is clean with no extra steps. - Keep records the file in your
alchemy.yml, and Alchemy runs that tool from your file, as-is, forever. More on this below.
The file init writes covers the whole pipeline: tests, lint, analyse, refactor and CI. That's because a section's presence is what opts a tool in. Don't want one? Delete its section and that tool never runs or installs.
Prefer no prompts? alchemy init --port or --keep answers for every file at once. Either way, init also wires the commands below into your composer.json.
Everyday commands β
leaf run test # run your tests
leaf run lint # check code style (changes nothing)
leaf run fmt # fix code style
leaf run refactor # apply Rector refactors
leaf run analyse # run PHPStan static analysis
leaf run ci # generate CI pipelines
leaf run alchemy # run everything at oncecomposer run test # run your tests
composer run lint # check code style (changes nothing)
composer run fmt # fix code style
composer run refactor # apply Rector refactors
composer run analyse # run PHPStan static analysis
composer run ci # generate CI pipelines
composer run alchemy # run everything at onceOne split worth internalizing early: lint only reports and exits non-zero when style is off, which is what CI needs. fmt is the command that rewrites your files. refactor follows the same idea with a --check flag for CI.
Testing β
A minimal setup is two sections: where your code lives, and how to test it.
app:
- app
- src
tests:
engine: pest # or phpunit
parallel: true
paths:
- tests
files:
- '*.test.php'app lists the directories with your application code. Coverage uses it, and lint, refactor and analyse default to it too, so you only say it once.
Inside tests:
engine:pestorphpunit. Alchemy installs your pick on the first run. Parallel mode uses Pest's built-in runner, or paratest for PHPUnit.pathsandfiles: where tests live and what they are called. The defaults aretests/and*.test.php.flags: standing flags passed to the engine on every run. Any Pest or PHPUnit option works. This is also where Pest 5's new toys live:
tests:
engine: pest
flags:
- tia # Pest 5 Test Impact Analysis: only re-run tests affected by your changesFor a one-off run, pass flags on the command line instead: composer run test -- --flags=tia.
The full phpunit.xml, without the XML
Everything you would normally reach into phpunit.xml for maps into the tests section: named suites, per-suite patterns and excludes, env/ini values, coverage excludes, and any root phpunit attribute passed through verbatim via config.
tests:
engine: pest
suites:
Unit:
paths:
- tests/unit
Feature:
paths:
- tests/feature
files:
- '*Test.php'
exclude:
- tests/feature/legacy
config: # any phpunit.xml attribute, passed through as-is
stopOnFailure: true
executionOrder: random
env:
APP_ENV: testing
DB_DATABASE: ':memory:'
ini:
memory_limit: 512M
coverage:
exclude:
- src/legacyUsing your own config files β
Any tool section can point at a file instead of holding a map. This is what a "keep" answer during init records, and you can write it yourself:
tests: phpunit.xml # run this tool from my file, as-is
analyse: phpstan.dist.neonA map section is Alchemy-managed (generated per run, discarded after). A string section runs the engine directly against your file, untouched. Because the choice lives in alchemy.yml, CI and every teammate get the same behavior. As a safety net, a tool with no section at all still runs against a matching config file it finds in your project.
Code style β
Style checks run through PHP CS Fixer, configured from the lint section. Every rule from the PHP-CS-Fixer Configurator works as-is:
lint:
preset: PSR12
risky: false # risky fixes are on by default
exclude:
- legacy
rules:
single_quote: true
no_unused_imports: true
array_syntax:
syntax: shortRemember the split: composer run lint checks and fails, composer run fmt fixes. If you would rather have CI fix style for you, set lint.autofix: true and the generated GitHub workflow will commit fixes instead of failing (GitHub only).
Just like the tests engine, the linter is swappable. Laravel projects usually already lint with Pint, so lint accepts a provider key:
lint:
provider: pint # phpcsfixer is the default
preset: laravelPint's rules are PHP CS Fixer rules, so your rules and exclude entries carry over unchanged. Presets map automatically (PSR12 becomes psr12, and so on), and Pint-only keys like notPath and notName pass through verbatim, so the section is never less expressive than a hand-written pint.json. alchemy init picks this for you: in a Laravel project it selects Pint with the laravel preset, and an existing pint.json ports into alchemy.yml completely.
Pint's runtime flags work too. Forward anything with --flags:
composer run fmt -- --flags=dirty # only fix files with uncommitted changesLaravel gets the same treatment on the analysis side: composer run analyse in a Laravel project installs Larastan and wires it in automatically, so PHPStan understands facades, Eloquent and container magic instead of drowning you in false positives.
Automated refactoring β
Alchemy manages Rector the same way, and a fresh alchemy init includes a refactor section with the safe starter sets (dead-code, code-quality, plus upgrade sets for your composer.json PHP version). composer run refactor installs Rector and applies them. Because Rector rewrites code, it only runs when this section exists. Deleting the section opts out entirely.
refactor:
php: '8.2' # upgrade sets targeting this PHP version (true = read from composer.json)
sets:
- dead-code
- code-quality
- type-declarations
skip:
- src/legacyIn CI, composer run refactor -- --check fails when refactors are pending, without changing anything.
All available sets and options
All twenty of Rector 2's prepared sets are available, kebab-cased: dead-code, code-quality, coding-style, type-declarations, type-declaration-docblocks, privatization, naming, named-args, instanceof, if, early-return, strict-booleans, carbon, rector-preset, phpunit-code-quality, phpunit-narrow-asserts, phpunit-mock-to-stub, doctrine-code-quality, symfony-code-quality, symfony-configs.
Other keys: paths (defaults to your app directories), import-names: true (import FQCNs and drop unused imports), fluent-new-line: true, and downgrade: '8.0' to rewrite syntax down to an older PHP version.
Static analysis β
A fresh alchemy init includes an analyse section (level 5), and PHPStan is installed and configured on your first composer run analyse. Tune it however far you want to go:
analyse:
level: 6 # 0 (loose) to 10 (strict)
ignore:
- '#some error pattern to ignore#'Analysis is check-only by nature: it exits non-zero when it finds problems, locally and in CI.
Two things happen for you automatically. A phpstan-baseline.neon at your project root is included if present (or point analyse.baseline elsewhere). And on Pest projects, when your analyse paths cover your tests, Alchemy installs Pest's first-party PHPStan plugin and wires it in, so it(), expect() and Pest's $this binding analyse cleanly.
Any phpstan parameter works
Any key under analyse that Alchemy doesn't recognize is passed through to phpstan verbatim, so the section is never less expressive than a hand-written neon file:
analyse:
level: 8
includes:
- vendor/phpstan/phpstan/conf/bleedingEdge.neon
excludePaths:
- tests
treatPhpDocTypesAsCertain: falseContinuous integration β
The actions section describes what CI should run, and where. Alchemy generates pipelines for one or more providers from the same configuration:
actions:
provider: github # or gitlab, circleci, or a list of them
run:
- lint
- tests
- analyse
php:
versions:
- '8.2'
- '8.3'
events:
- push
- pull_requestcomposer run ci writes .github/workflows/*.yml, .gitlab-ci.yml, or .circleci/config.yml depending on your providers. Lint, refactor and analyse jobs all run in check mode: CI gates your code, it never rewrites it. GitHub configs also take os for a runner matrix, and php.extensions for extensions.
Generated CI files carry a # Generated by Leaf Alchemy header and are regenerated on every run, so action versions and pipeline fixes stay current when you update Alchemy. Remove the header from a file to take ownership, and Alchemy will never touch it again.
Moving CI providers is one command, because everything is generated from the same yml:
./vendor/bin/alchemy switch gitlab --cleanThis updates your config, generates the new provider's pipeline, and removes the old provider's files (--clean). The same command switches test engines: alchemy switch phpunit.
Alchemy in a Laravel project β
Alchemy works in any PHP project, and Laravel is the framework it adapts to most carefully. Laravel already has opinions about QA tooling, and Alchemy's job there is to respect every one of them, then handle the part Laravel doesn't: keeping five tool configs and your CI in sync.
Run alchemy init in a project that requires laravel/framework and this is the entire file you get, shaped like a Laravel project and not like anyone else's:
app:
- app
tests:
engine: pest # or phpunit, init picks whichever your project already uses
lint:
provider: pint
preset: laravel
analyse:
level: 5
refactor:
php: true # upgrade sets for the PHP version in your composer.json
sets:
- dead-code
- code-quality
actions:
run:
- lint
- tests
events:
- push
- pull_requestThat file is the whole pipeline: style, tests, static analysis, refactoring and CI. Don't want one of them? Delete its section and it never runs or installs. CI starts with the two universal jobs; add analyse or refactor to actions.run when you want them gating merges too.
Here is what that means in practice:
Pint stays your linter. No preset translation, no switching to PHP CS Fixer. If you have a pint.json, init ports it completely: rules and excludes carry over as they are, and Pint-only keys like notPath and notName round-trip verbatim. Prefer to keep the file? Answer "keep" and Alchemy runs Pint from your pint.json untouched, forever. Runtime flags work too:
composer run fmt -- --flags=dirty # only fix files with uncommitted changesYour tests stay your tests. An existing phpunit.xml ports into the yml (suites, env values, the <php> block) or stays pinned as your own file. Pest projects run Pest. Nothing about how you write tests changes.
Static analysis actually understands Laravel. The analyse section is in the file from day one, and your first composer run analyse installs PHPStan and Larastan, wired in automatically. Facades, Eloquent models and container magic analyse cleanly instead of burying you in false positives. A phpstan-baseline.neon at your root is respected, so a legacy app can adopt analysis without a wall of day-one errors.
CI writes itself. This is the part no Laravel dev enjoys hand-writing. composer run ci generates GitHub Actions workflows with a PHP version matrix from the same yml, and switching to GitLab CI or CircleCI is one provider line. Set lint.autofix: true and CI commits Pint's fixes instead of failing the build.
And you can leave whenever you want. alchemy eject exports a real phpunit.xml and pint.json, points your composer scripts straight at the engines, and tells you how to remove Alchemy. Trying it costs nothing.
The short version: everything you already chose stays chosen. Alchemy just collapses the config sprawl around those choices into one file, and generates the CI you were going to copy-paste anyway.
Upgrading from Alchemy 4 β
Alchemy 5 grows from a test/lint setup helper into the full pipeline on this page. Your existing setup keeps working: the old commands (alchemy setup --test, --lint, --actions) still exist as aliases, so v4-era composer scripts run unchanged.
The upgrade for existing projects:
composer require leafs/alchemy:^5.0 --dev
./vendor/bin/alchemy init --force # refreshes composer scripts + asks port-or-keep for your configsThen use the new commands: composer run test, lint, fmt, refactor, analyse, ci.
Behavior changes to know about β
- Exit codes are real now. Alchemy 4 always exited
0, even when your tests failed, so CI built on it could never go red. Alchemy 5 propagates real exit codes. If your pipeline starts failing after upgrading, that's the fix working: it was failing before too, silently. - Lint checks,
fmtfixes.composer run lintfails on violations without rewriting anything (this is what generated CI runs), andcomposer run fmtdoes whatlintused to do. Want CI to auto-commit style fixes instead of failing, like v4 did? Setlint.autofix: true(GitHub only). event:is nowevents:. v4's stub wroteevent:but the code readevents, so custom CI triggers were silently ignored and every workflow ran onpushonly. Both keys are accepted, but rename toevents:and your configured triggers actually apply.- PHPUnit parallel uses paratest.
tests.parallel: truewith the phpunit engine used to pass a--parallelflag PHPUnit doesn't have. Alchemy 5 installs and runs paratest instead. Pest keeps its built-in parallel mode. - Your
phpunit.xmlis safe, and never touched. v4 could overwrite and delete a hand-writtenphpunit.xml. Alchemy 5 generates its config inside.alchemy/and discards it after the run.alchemy initasks whether to port your config intoalchemy.yml(the original is parked at.alchemy/phpunit.xml.bak) or pin it (tests: phpunit.xml) and run from your file forever. config:ejectis noweject, and it works. The old eject command targeted a config format that no longer existed.alchemy ejectexports a realphpunit.xml+.php-cs-fixer.dist.phpand rewires your composer scripts to call the engines directly.- Composer scripts work on Windows. Scripts are written as
@php vendor/bin/alchemy ..., so the samecomposer run testworks on every platform.
Unpin your engines β
Older alchemy versions installed engines with composer require pestphp/pest --dev, which froze the constraint at whatever major was current, so newer majors never arrive even on a PHP that supports them. Alchemy 5 installs with pestphp/pest:* instead: composer always resolves the newest version your PHP allows. If your composer.json carries an old pin, unpin once and you're future-proof:
composer require 'pestphp/pest:*' --dev --with-all-dependencies(Same idea for phpunit/phpunit, phpstan/phpstan or rector/rector if an older setup pinned them.)
Config keys that moved β
| v4 | v5 |
|---|---|
actions.event | actions.events (old key still read) |
tests.config.xmlnxsi | tests.config['xmlns:xsi'] (old key still read) |
| β | tests.suites, tests.env/ini/const/server, tests.coverage.exclude, tests.extensions, tests.flags |
| β | lint.provider, lint.risky, lint.exclude, lint.autofix |
| β | refactor.*, analyse.*, actions.provider |
Leaving Alchemy β
No lock-in means a real exit:
./vendor/bin/alchemy ejectThis exports your configuration to a standard phpunit.xml and .php-cs-fixer.dist.php (or pint.json when your provider is Pint), points your composer test/lint scripts directly at the engines, and tells you how to remove Alchemy. Your tests don't change. They were always plain Pest/PHPUnit tests.
