One source of truth for code style across every Matchory repository: Pint, PHPStan and Rector for
PHP; oxlint, oxfmt, ESLint and TypeScript for JavaScript; ruff for Python; and the canonical
.editorconfig shared by all three.
Edit configuration here. Never in a consumer.
Configuration was copied between repositories and then drifted. A survey across our repositories in July 2026 found, for the same organisation and the same conventions:
| Drift | Detail |
|---|---|
| JS formatting | Two indentation widths and two quote styles across JavaScript repositories |
| JS lint coverage | One repository enforced ~73 oxlint rules; another shipped an empty rule set |
| JS config format | Some repositories used oxlint.config.ts, others .oxlintrc.json |
| Python line length | Two different limits in active services |
| Python rule sets | One service selected ~46 ruff rule groups, another 8 |
| Python coverage | Several services had no ruff configuration at all |
None of this was anyone's fault. Copying a config file is the path of least resistance, and there was nothing to copy from that stayed current.
Only half of these tools can read configuration out of a package. That distinction drives the whole design, so it is worth stating plainly:
| Tool | Mechanism | Extends? |
|---|---|---|
| PHPStan | includes: a vendor path, plus auto-discovery |
Yes, natively |
| Rector | A PHP call: Preset::laravel(RectorConfig::configure()) |
Yes, natively |
| ESLint | Flat config import |
Yes, natively |
| TypeScript | extends a package path |
Yes, natively |
| oxlint / oxfmt | Spread the exported object in a .config.ts |
Yes, when config is TS |
| Pint | --config vendor/…; accepts preset and rules only |
No merge |
| ruff | extend takes a path, and site-packages paths are unstable |
No, copied |
| EditorConfig | root = false only walks up the directory tree |
No, copied |
For the bottom three the package is a transport for files, refreshed by a sync command and verified in CI. There is no way around it, and pretending otherwise is how configuration goes stale without anyone noticing.
Each ecosystem's location is forced by its packaging rules, which is why the tree looks asymmetric:
composer.json PHP package. Composer has no `#subdirectory`, and Repman reads
composer.json from the repository root.
pyproject.toml Python package. `pip install git+…` and pre-commit both expect
src/matchory_coding_style/ project metadata at the root.
.pre-commit-hooks.yaml
js/ npm package. The only one that can live in a subdirectory, so it does.
php/ Pint, PHPStan and Rector presets, plus the PSR-4 source.
.editorconfig Canonical copy. Mirrored into the npm and Python packages by CI-verified
generators.
One consequence: all three packages share a single version tag. A change touching only oxlint still bumps the version PHP consumers see. That is deliberate — "every repository is on style v3" is a useful thing to be able to say — but expect Renovate noise and group the updates.
composer require --dev matchory/coding-styleRequires PHP 8.5. The package requires Pint, PHPStan, Rector and
tomasvotruba/cognitive-complexity, so it owns those versions centrally. Install
larastan/larastan, driftingly/rector-laravel and pestphp/pest-plugin-rector alongside it for the
Laravel and Pest presets.
Pint does not merge configuration: --config selects exactly one file. Point your fmt script at a
preset:
{
"scripts": {
"fmt": "pint --config vendor/matchory/coding-style/php/pint/base.json --parallel --cache-file=./.cache/pint.json",
"fmt:test": "@fmt --test",
"fmt:pre-commit": "@fmt --dirty --repair"
}
}| Preset | Contents |
|---|---|
base.json |
38 rules on top of the per preset. The target for every repository. |
relaxed.json |
base minus the 14 docblock-rewriting rules. An adoption ramp, not a home. |
relaxed exists because the docblock rules produce by far the largest diff when a repository first
adopts the shared style, and that diff is what stalls adoption. It is generated from base.json by
php php/bin/generate-pint-presets.php, so the rules have one source.
Because --config replaces rather than merges, a repository needing one local deviation has no
escape hatch. If that comes up, merge at invoke time instead of forking the preset:
{
"scripts": {
"fmt:config": "@php -r \"file_put_contents('.cache/pint.merged.json', json_encode(array_replace_recursive(json_decode(file_get_contents('vendor/matchory/coding-style/php/pint/base.json'), true), file_exists('pint.json') ? json_decode(file_get_contents('pint.json'), true) : [])));\"",
"fmt": ["@fmt:config", "pint --config .cache/pint.merged.json --parallel"]
}
}php/phpstan/base.neon is applied automatically to every consumer: the package declares
type: phpstan-extension and extra.phpstan.includes, which phpstan/extension-installer discovers
on install. It carries only settings that are safe in any PHP codebase — editor URLs,
treatPhpDocTypesAsCertain, reportUnmatchedIgnoredErrors.
It deliberately does not set level, paths or tmpDir. A level applied invisibly from vendor
is a debugging trap, and a cache directory a consumer has not gitignored would start committing
analysis caches.
Everything else is opt-in:
includes:
- vendor/matchory/coding-style/php/phpstan/laravel.neon
- vendor/matchory/coding-style/php/phpstan/pest.neon
- vendor/matchory/coding-style/php/phpstan/complexity.neon
parameters:
level: 5
paths:
- app/
- tests/
# Must be declared here; see the merge-order note below.
cognitive_complexity:
class: 50
function: 15| Preset | Contents |
|---|---|
base.neon |
Auto-applied. Universal settings only. |
pest.neon |
The TestCall/Expectation ignores, universalObjectCratesClasses, stubs. |
laravel.neon |
disableMigrationScan/disableSchemaScan, the HasEvents ignore. |
complexity.neon |
Complexity exemptions for */tests/* and */migrations/*. |
strict.neon |
level: 9 and treatPhpDocTypesAsCertain: true. |
Three caveats, all verified against a real consumer rather than assumed:
- A package can only set a parameter that no other auto-discovered extension sets. PHPStan merges
extension configs after every
includes:but before the root config's ownparameters, so the extension that sorts last wins and only your ownphpstan.neonbeats all of them. This is why the complexity thresholds are documented rather than shipped:tomasvotruba/cognitive-complexitydeclares its own defaults and sorts aftermatchory/coding-style, so any value this package set would be silently discarded. Check withphpstan dump-parameters. ignoreErrorsfrom an include is appended, never replaced. There is no way to un-ignore locally, which is why the shared entries are limited to things that are safe unconditionally. This is also why the preset files can carry ignores but not thresholds.levelinstrict.neononly applies if your ownphpstan.neondoes not set one. A locallevel: 5silently wins.
Stays local: your paths, excludePaths, baseline, complexity thresholds, and any ignore that names
a specific file.
<?php
declare(strict_types=1);
use Matchory\CodingStyle\Rector\Preset;
use Rector\Config\RectorConfig;
return Preset::laravel(RectorConfig::configure())
->withPaths([__DIR__ . '/app', __DIR__ . '/tests'])
->withCache(cacheDirectory: __DIR__ . '/.cache/rector/run')
->withSkip([
// File-specific skips stay here; they do not generalise.
SomeRector::class => [__DIR__ . '/app/Legacy'],
]);| Preset | Contents |
|---|---|
base() |
PHP version sets (read from your composer.json), prepared sets, import handling, universal skips. |
library() |
base() plus PHPUnit and, when installed, Pest. For composer packages. |
laravel() |
library() plus the Laravel sets and Laravel-specific skips. |
pest() |
The Pest rules alone. Applied by the two above. |
withPaths(), withSkip(), withRules() and withSets() all merge, so anything you call after a
preset adds to it. Framework sets are applied only when the package providing them is installed, so
the same preset works in a Laravel application and a plain script; a missing provider downgrades the
preset rather than failing it.
Applying a preset twice is a no-op. withPhpSets() throws on a second call, and because the presets
build on one another, Preset::laravel(Preset::base($config)) would otherwise reach base() twice
and fatal.
The Pest rules come from pestphp/pest-plugin-rector. Its PestSetList::CODING_STYLE is not
imported: the set bundles 59 rules, including chain-manipulating ones (ChainExpectCallsRector,
EnsureTypeChecksFirstRector) of the kind that corrupted chained expectations under the abandoned
package this replaced, plus one-shot Pest 2 → Pest 3 migration rules that should not run on every
pass. Eight individually vetted rules are enabled instead. Opt into the full set locally if you want
it, and read the diff:
return Preset::laravel(RectorConfig::configure())
->withSets([\Pest\Rector\Set\PestSetList::CODING_STYLE]);vendor/bin/matchory-coding-style sync # write .editorconfig
vendor/bin/matchory-coding-style sync --check # CI
vendor/bin/matchory-coding-style verify # is this project actually wired up?
vendor/bin/matchory-coding-style verify --strict # treat warnings as failuresverify exists because depending on the package is not the same as using it. It checks that a
composer script passes a Pint preset via --config, that extension-installer really picked up
base.neon, that cognitive_complexity is declared locally (the one parameter that cannot be
shipped), and that rector.php calls a preset. Warnings cover things that legitimately vary per
repository, like not using Rector at all.
pnpm add -D @matchory/coding-stylePublished to both npmjs and GitHub Packages, as identical bytes from the same attested tarball, so
it resolves whichever way your .npmrc points the @matchory scope.
That is not redundancy for its own sake. npm resolves a registry per scope and has no per-package
override, and @matchory/ui is proprietary so it lives on GitHub Packages. A repository consuming both
must therefore point the whole scope at one registry. Publishing to both is what lets it.
npmjs is canonical: it is public, and it is the copy that carries
provenance. Prefer it unless you already map
the scope to GitHub Packages for @matchory/ui.
ESLint plugins are regular dependencies so their versions are pinned centrally — that is the point
of the package. The tools themselves (eslint, oxlint, oxfmt, typescript) are optional peer
dependencies, so a repository that only wants formatting is not forced to install ESLint. The
tradeoff is that a non-Vue repository still pulls the Vue plugins; if that becomes a real cost, move
them to optional peers.
// oxfmt.config.ts
import { oxfmtBase } from '@matchory/coding-style/oxfmt';
export default {
...oxfmtBase,
ignorePatterns: [...oxfmtBase.ignorePatterns, 'storage/**'],
};// oxlint.config.ts — use /oxlint for TS-only packages
import { oxlintVue } from '@matchory/coding-style/oxlint/vue';
export default { ...oxlintVue, ignorePatterns: ['dist', '.cache'] };// eslint.config.js
import { withVue } from '@matchory/coding-style/eslint/vue';
import { resolve } from 'node:path';
export default withVue(
{ tailwindEntryPoint: resolve(import.meta.dirname, 'src/style.css') },
[{ files: ['**/*.spec.ts'], rules: { 'id-length': 'off' } }],
);| Export | Use for |
|---|---|
./oxfmt |
Everything. Canonical formatting: 4-space, single quotes, 100 columns. |
./oxlint |
TS/JS packages. 73 rules, no Vue plugin. |
./oxlint/vue |
Packages containing .vue files. |
./eslint |
TS/JS packages: core, withCore(). |
./eslint/vue |
Vue packages: vue(), withVue(), vueConfigs(). |
./tsconfig/base.json |
Plain TypeScript. |
./tsconfig/vue.json |
Vue applications and component libraries. |
./tsconfig/node.json |
CLIs, GitHub Actions, build scripts. No DOM libs. |
./tsconfig/strict.json |
Extra strictness, composed after a base. Opt in per repository. |
Tailwind class ordering is owned by eslint-plugin-better-tailwindcss, not oxfmt's sorter: the
plugin is theme-aware through its stylesheet entry point and additionally reports unknown classes.
Pass tailwindEntryPoint or the Tailwind rules are skipped entirely.
.oxlintrc.json and .oxfmtrc.json cannot import a JavaScript object. Rather than making a
config-format migration the price of adopting the shared style, the same source objects are
serialised to generated/ and copied in:
npx matchory-coding-style sync --rc --vue
npx matchory-coding-style sync --rc --vue --check # CIMigrating to oxlint.config.ts is still better, and then --rc is unnecessary.
npx matchory-coding-style verify # is this project actually wired up?
npx matchory-coding-style verify --strict # treat warnings as failuresChecks that the oxfmt, oxlint and ESLint configs import the presets (or that the rc files match a
synced preset), and that tsconfig.json extends one. A config file that exists but does not import
the preset is reported as a failure, not a pass: that is the case a presence check would miss.
uv add --dev "matchory-coding-style @ git+ssh://git@github.com/matchory/coding-style@v0.1.0"
matchory-coding-style sync --preset baseThen point pyproject.toml at what it wrote and commit both:
[tool.ruff]
extend = ".matchory/ruff.toml"| Preset | Contents |
|---|---|
base.toml |
10 rule groups. Sized to be adoptable in a repository with no config today. |
strict.toml |
Extends base with 30 more groups. Derived from our strictest existing service. |
sync copies both presets plus a one-line selector, because strict.toml extends base.toml by
relative path. Select the preset with --preset; --check verifies without writing.
matchory-coding-style verify # is this project actually wired up?
matchory-coding-style verify --strict # treat warnings as failuresverify reads the selected preset back out of .matchory/ruff.toml, so a repository on strict is
not told it has drifted from base. A [tool.ruff] section that inlines its own rules instead of
extending the copy is a failure rather than a pass.
Prefer wiring it through pre-commit, which pins the version and the ruff binary together:
repos:
- repo: https://github.com/matchory/coding-style
rev: v0.1.0
hooks:
- id: matchory-coding-style # fails if the copied config has drifted
- id: matchory-ruff
- id: matchory-ruff-formatThis repository's own updates are handled by Dependabot (.github/dependabot.yml): grouped per
ecosystem, with a five-day cooldown on every one. The cooldown is a supply-chain control rather than a
convenience — see SECURITY.md.
Note that Renovate is not installed on the organisation. Dependabot is what actually runs. The presets below are for consumers that adopt Renovate, and express the same policy.
This repository also ships an org-wide Renovate config, extended by name the same way the style presets
are. Using it requires installing the Renovate GitHub App. In a consuming repository's
renovate.json:
{
"extends": [
"github>matchory/coding-style",
"github>matchory/coding-style//renovate/php",
"github>matchory/coding-style//renovate/javascript"
]
}| Preset | Contents |
|---|---|
default.json (no path) |
Schedule, dependency dashboard, semantic commits, grouped dev tooling, and a no-automerge rule for linter majors. Includes the consumer preset below. |
//renovate/coding-style-consumer |
Collapses this package's three artefacts into one PR. |
//renovate/php |
Laravel ecosystem grouping, held-back framework majors, static-analysis minors kept separate. |
//renovate/javascript |
Vue and Vite groupings, ESLint plugins separated, oxlint/oxfmt never automerged. |
//renovate/python |
ruff held back and never automerged, grouped scientific stack. |
The consumer preset is the one that earns its keep. Because all three packages share a version tag, a polyglot repository would otherwise get three pull requests for one upstream commit.
Requires Renovate 38 or newer, which is when matchPackageNames gained glob support.
Pick one tool per repository. Dependabot security updates compose fine with Renovate, because they only fire on advisories. Dependabot version updates do not: every bump would arrive twice. Our repositories use Dependabot, so these presets are only relevant if that changes.
php php/bin/generate-pint-presets.php --check # pint/relaxed.json is derived from base.json
cd js && node scripts/generate.mjs --check # generated/*.json are derived from src/CI runs both with --check, plus assertions that every PHPStan preset parses, the Rector presets
load, the ruff presets resolve through a real ruff run, and the three .editorconfig copies are
identical. Regenerate and commit rather than editing anything under js/generated/ or
src/matchory_coding_style/data/.
Adoption is tracked in the issue tracker rather than here, so it cannot go stale.
Two notes for anyone migrating a repository:
- A repository formatted at a different indentation or quote style needs a one-time reformat commit. Land it separately from the version bump, or review becomes unreadable.
- Vitest configuration deliberately does not live here. It is build tooling rather than code style, and in practice it is heavily application-specific: route-manifest generation, module aliases, dependency inlining. Keep it local.
Run verify after wiring a repository up; it will tell you what is still pointing at local config.