paolobellini / laravel-preset
Opinionated Laravel preset: dev tooling, code conventions and AI agent guidelines scaffolded into new projects.
Requires
- php: ^8.3
- illuminate/console: ^11.0 || ^12.0 || ^13.0
- illuminate/filesystem: ^11.0 || ^12.0 || ^13.0
- illuminate/process: ^11.0 || ^12.0 || ^13.0
- illuminate/support: ^11.0 || ^12.0 || ^13.0
Requires (Dev)
- laravel/pint: ^1.27
- orchestra/testbench: ^11.1.0
- pestphp/pest: ^4.0 || ^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Opinionated personal Laravel preset. One command scaffolds the dev tooling, code conventions and CI used across new projects (on top of the Laravel + Vue starter kit, so anything the starter kit already ships is not duplicated).
Install
composer require paolobellini/laravel-preset --dev php artisan preset:install
The packages and scripts groups add their dependencies with
composer require (prefixed with ./vendor/bin/sail when
Laravel Sail is installed and the command runs on the host — inside the
container, as with sail artisan preset:install, plain composer is used). No version constraint is ever passed, so composer
resolves the newest stable release compatible with the project — the preset
never carries a stale version around. Pass --no-install to write the resolved
constraints into composer.json without installing.
What it does
php artisan preset:install walks through the choices one step at a time and
installs nothing until the plan is confirmed:
| Step | Question | Groups |
|---|---|---|
| 1 | Where the project's commands run (only on a host with Sail) | — |
| 2 | Which optional runtime packages | packages — always installed |
| 3 | Development tooling, yes or no, as one block | configs, scripts |
| 4 | Which AI tooling | ai, skills, codegraph |
| 5 | Which automation | github, lefthook |
| 6 | The plan, then Install the preset as planned? | — |
Passing a group flag (see Flags) skips the steps and installs exactly the flagged groups.
The confirmed choices are saved to preset.json at the project root — commit
it. The next run finds it and asks Reuse the choices saved in preset.json?
instead of walking through the steps again; a non-interactive run follows it
without asking. Flagged runs neither read nor write it.
{
"runtime": "sail",
"packages": ["data", "query-builder"],
"groups": ["packages", "configs", "scripts", "ai", "github"]
}
packages — runtime packages
nunomaduro/essentials and thecodingmachine/safe always go into require.
Three more are optional and asked for one by one:
| Package | Offered |
|---|---|
spatie/laravel-data |
always |
spatie/laravel-query-builder |
always |
spatie/laravel-typescript-transformer |
only when the project requires inertiajs/inertia-laravel |
A non-interactive run takes every package offered. All of them go into
require — the application loads them at runtime, so they must survive
composer install --no-dev. One of them found in require-dev is moved to
require.
Also copies config/essentials.php — the nunomaduro/essentials overrides
(Unguard => true, inverse of the package default).
configs — lint / format / static analysis
Copies the configs not already in the starter kit:
| File | Tool |
|---|---|
pint.json |
Laravel Pint (strict types, final classes, phpdoc-only types) |
phpstan.neon |
Larastan level 7 + the pest-plugin-phpstan and phpstan-safe-rule extensions |
rector.php |
Rector + rector-laravel sets, scoped to app/ and database/ |
rector-tests.php |
Rector for tests/ — LARAVEL_TESTING + PestSetList::CODING_STYLE |
psalm.xml |
Psalm, scoped to taint analysis only (errorLevel="8") |
tests/Pest.php |
created when missing (extend(TestCase) + RefreshDatabase), otherwise patched with pest()->tia()->locally() |
ai — conventions
Copies the .ai/ directory and adds laravel/boost to require-dev:
.ai/guidelines/personal/*— precedence, comments, commits, controllers (action pattern), actions, caching, enums, exceptions, form-requests, frontend, models, pest-agent, php (Safe functions), policies, query-builder, resources, testing, traits, translations, typescript, workflow..ai/mcp/mcp.json.
query-builder.md and typescript.md are copied only when their package is
part of the project — picked in the same run, or already in require. The
guidelines that merely mention an optional package (controllers.md,
frontend.md) say what to do without it.
scripts — composer quality scripts + dev deps
Merges the scripts into composer.json without clobbering existing keys, then
installs the dependencies with composer require — always unconstrained, so
every install picks up the current stable release.
Added to require-dev (anything already required is left untouched, use
--force to re-require it at the latest version): fruitcake/laravel-debugbar,
larastan/larastan, laravel/pint, laravel/pail,
rector/rector, driftingly/rector-laravel, pestphp/pest and the
pest-plugin-{type-coverage,mutate,rector,phpstan,evals,agent,faker} plugins,
thecodingmachine/phpstan-safe-rule, vimeo/psalm.
nunomaduro/collision and pestphp/pest-plugin-laravel are not added —
they already ship with the starter kit.
config.allow-plugins gets pestphp/pest-plugin so the pest plugins can boot.
Composer scripts are layered so a tool is declared once:
- leaf — one tool each:
pint,pint:dry,rector,rector:dry,rector:test,rector:test:dry,stan,taint,test,test:type-coverage,test:coverage,test:mutate,update-shards. - groups —
analyse:static(pint + stan + rector onapp/),analyse(analyse:static+taint),tests(type-coverage + coverage),ci:node(npm lint/format/types). - entry points —
ci(ci:php+ci:node),ci:php(analyse+tests),pre-push(rector:test:dry+test:mutate),pre-commit(analyse:static+test+ci:node, the by-hand equivalent of the hook).
Entry points compose groups and never re-list a leaf, so adding a tool means editing one group.
CI and pre-push are deliberately disjoint. CI owns the application
contract — pint, stan, taint, rector over app/, coverage, type-coverage — so
it is asynchronous and cannot be bypassed. pre-push owns test-suite quality:
the mutation score and rector over tests/, the two slowest checks and the two
whose audience is the author. Nothing runs twice.
Every script that boots the application (test:*) is prefixed with
@php artisan config:clear --ansi @no_additional_args — a cached
bootstrap/cache/config.php silently overrides config/ and .env, and
@no_additional_args stops Composer from appending your own options to
config:clear. The lint/analysis scripts do not boot the app and skip it.
composer taint→psalm --taint-analysis --no-cache. Psalm is installed only for this: PHPStan/Larastan stays the static analyser, andpsalm.xmlruns at the most permissive error level so the report is about tainted input reaching a sink, not about typing.composer test:mutate→pest --parallel --mutate --covered-only --min=65, withComposer\Config::disableProcessTimeoutso a long mutation run is not killed at Composer's 300s limit.
npm deps and scripts are not touched — the starter kit already provides
ESLint, Prettier, TypeScript and their lint/format/types:check scripts.
lefthook — pre-commit hooks (opt-in)
Copies lefthook.yml, with two hooks and one job per tool — granular on
purpose, since lefthook only parallelises what it sees as separate jobs and a
failure in one still reports the others.
pre-commitis scoped to{staged_files}with aglobper job, so a commit touching one.vuenever starts a PHP job. Pint, Prettier and ESLint fix and re-stage (stage_fixed) instead of failing, so a commit is never blocked by formatting; PHPStan analyses the staged files and Pest runs with the coverage gate (cheap locally, since TIA replays everything untouched).pre-pushruns only what CI does not:rector:test:dryandtest:mutate.
tests/Pest.php is the one file the preset patches rather than copies: it
holds project-specific setup, so an existing one only gets
pest()->tia()->locally() appended (and nothing at all if it already configures
TIA). That line is what makes the commit hook affordable — locally a run
replays whatever the change did not touch, while CI still measures the full
suite.
Passing paths to PHPStan and Rector narrows them to those files — an error caused elsewhere will not show up until CI. That is the trade that keeps the commit hook fast.
Paths given on the command line also replace the paths of phpstan.neon, so
the stan job's glob and exclude repeat them: a staged rector.php or
migration is never handed to PHPStan. Change one, change the other.
Every job — the Node ones included (vendor/bin/sail npx prettier, … eslint)
— is prefixed with vendor/bin/sail when Sail is detected in the project, so a
hook runs the same PHP and Node as sail composer ci, never whatever the host
happens to have. Without Sail the prefix is stripped from the copied file.
Opt-in — unlike the other groups it is never selected by default: pass
--lefthook, or tick it in the interactive prompt.
Lefthook is not a composer package: install the binary once per machine
(brew install lefthook, or npm i -D lefthook), then run lefthook install
in the project to wire up .git/hooks.
Installing the group also clears the ground for a single agent: the scaffolding
boost:install wrote for the others (.amp, .codex, .cursor, .factory,
.gemini, .grok, .junie, .kiro, .pi, .zed, and
.github/copilot-instructions.md) is deleted, and boost.json is pinned to
agents: ["claude_code"].
The pin is the part that makes it stick. Boost only falls back to detecting
agents when boost.json names none, and its detection is generous — Junie
counts itself present because .idea exists, or because PhpStorm is installed
on the machine at all. Delete the directories without pinning and the next
boost:install writes them back.
.idea and .vscode are never touched: Boost writes into them, but they hold
your editor's own settings.
github — CI workflows and dependency updates
First removes the starter-kit lint.yml + tests.yml (superseded), then copies
the workflows. Each job runs the composer scripts from the scripts group
directly, so the commands and their definitions stay in this repo and cannot
drift apart. Only the environment setup is shared, via the composite actions in
paolobellini/bellini.one.
.github/workflows/analyse.yml— on push tomain/ any PR, runscomposer analyse(pint, phpstan, rector, psalm taint) andcomposer ci:node. The Node step carriesif: ${{ !cancelled() }}so a PHP failure still reports the front-end result in the same run; the job fails either way. It is also gated ondorny/paths-filter, which skips it when no front-end file changed — a Composer-only bump PR has nothing for eslint ortscto say, and neither does a documentation-only one. The filter is written as "everything except the PHP side, the docs and the agent conventions" (**plus negations, withpredicate-quantifier: some-with-excludes), so an unrecognised new file runs the checks rather than silently skipping them. Note that!**/*.mdalso takes Markdown out ofnpm run format:check; Prettier still formats it at pre-commit, wherestage_fixedwrites the result back before the file is ever committed..github/workflows/tests.yml— on push tomain/ any PR, runscomposer tests(type coverage + coverage) against a database service container. The engine comes from theDB_CONNECTION,DB_IMAGE,DB_PORTandDB_OPTIONSrepository variables, defaulting to MySQL 8.0 when they are unset (see below)..github/workflows/security.yml— on PR targetingmain,stagingordev, a Trivy filesystem scan (vuln+secret) failing onHIGH,CRITICAL, plus a second report-only pass that lists the vulnerabilities with no released fix instead of hiding them behindignore-unfixed..github/trivy.yaml— points Trivy at the VEX document..vex/openvex.json— the triage record (see below). Created empty, with its@idderived from the project (urn:vex:vendor:project, fromcomposer.json) so two projects never share one, and never overwritten without--force..github/dependabot.yml— weekly Composer, npm and GitHub Actions updates. Minor and patch bumps are grouped into one PR per ecosystem and per production/development split, so the usual flood becomes a handful of PRs; major bumps stay on their own, because those are the ones worth reading. Commit prefixes follow the repo convention (chore(deps),chore(deps-dev),chore(ci)). Acooldownholds each release back before it is proposed — 3 days for a patch, 7 for a minor, 14 for a major — so a version pulled hours after publication never reaches a PR; security updates ignore the cooldown entirely. An existingdependabot.ymlis never overwritten without--force.
Triaging a vulnerability with OpenVEX
A scanner has no memory: without one, a vulnerability you assessed months ago
blocks every pull request forever. The record of that assessment is a VEX
document, and .vex/openvex.json is it.
Most findings never reach it — a CVE with a released fix is answered by bumping the dependency, and the finding disappears on its own. A statement is for the other case: no fix exists, or one exists and cannot be applied, and you have established that the project is not exposed.
{
"vulnerability": { "name": "GHSA-94pj-82f3-465w" },
"products": [ { "@id": "pkg:composer/guzzlehttp/guzzle@7.9.2" } ],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path"
}
The spec requires version to be incremented whenever the document's content
changes, so bump it in the same pull request as the statement. Trivy does not
check it; a VEX consumer that merges documents from several sources does.
Trivy matches on the package URL, drops the finding when the status is
not_affected or fixed, and leaves it in place for affected and
under_investigation — so a half-finished assessment keeps the build red. Where
several statements match, the last one wins: change your mind by appending, not
by editing, and the reasoning stays in the history.
Including the version pins the statement to it, and a bump brings the finding
back for a fresh assessment. Dropping the version (pkg:composer/vendor/name)
makes it apply to every version, forever — defensible when the justification is
about your own code rather than the dependency, but it never expires, so prefer
the pinned form.
OpenVEX rather than the CycloneDX VEX that Dependency-Track exports, for two
reasons found in the source rather than the documentation: Trivy supports
CycloneDX VEX only when scanning an SBOM, not a filesystem; and
Dependency-Track's exporter omits the component list for that variant, leaving
every affects[].ref pointing at the application itself rather than at the
vulnerable package. OpenVEX identifies products by package URL, so it needs no
companion document.
Keep each statement in its own pull request. The GitHub pull_request event
builds a merge ref, so as soon as a statement lands on the base branch every
open pull request against it sees it — one small merge unblocks the rest, and a
security decision gets reviewed on its own rather than buried in a feature
branch. A CODEOWNERS entry for /.vex/ is worth adding: it is the one place
in the repository where a line makes a security alert disappear.
Dependency-Track, if you run it, keeps its own role: it holds the portfolio view
across projects and sends the NEW_VULNERABILITY notification. It is not in the
critical path of CI, and its triage is not what the gate reads.
Renovate instead of Dependabot
--renovate writes .github/renovate.json and deletes .github/dependabot.yml,
so only one bot ever watches the repo. The config mirrors the Dependabot one —
weekly Monday window, minor/patch grouped per manager and per production/
development split, majors on their own, minimumReleaseAge of 3/7/14 days by
update type — and adds two things Dependabot has no equivalent for:
lockFileMaintenancerefreshes the whole lockfile once a month, picking up transitive dependencies that no direct constraint mentions.dependencyDashboardopens a single issue listing everything pending, so updates can be reviewed without a PR being opened for each.
vulnerabilityAlerts sets minimumReleaseAge back to null and schedules
at any time: the soak period must not delay a security fix.
Read schedule as a permission window, not a trigger: Renovate runs on its own
cadence and skips the repository when it falls outside. * 0-6 * * 1 is seven
hours on Monday; * * 1-7 * 1 restricts both day of month and weekday, which
Renovate reads as an AND — the first Monday of the month, with a full day to
land in.
Unlike Dependabot, the file alone does nothing — Renovate is not built into
GitHub. Either install the hosted Renovate GitHub App on the repository, or add
--renovate-selfhosted.
--renovate-selfhosted
Installs .github/workflows/renovate.yml, which runs Renovate from the official
action. Use it when the GitHub App cannot be installed — no
admin rights on the organisation, or a policy against third-party apps with
write access. It implies --renovate, so the config file is installed too.
The workflow needs a RENOVATE_TOKEN secret. Do not point it at the default
GITHUB_TOKEN: pull requests opened with it do not trigger workflows, so
analyse and tests would never run on an update PR and you would be merging
unverified bumps. Use a personal access token, or a GitHub App token, with
contents and pull-request write access.
The cron covers exactly the window the config allows, in both DST regimes —
Monday 00:00–06:00 in Europe/Rome is Sunday 22:00–Monday 05:00 UTC in summer
and Sunday 23:00–Monday 06:00 UTC in winter:
schedule: - cron: '0 22,23 * * 0' - cron: '0 0-6 * * 1'
Nine runs a week rather than the fifty-six an every-three-hours cron would cost. Outside the window Renovate still boots, clones and analyses before deciding to do nothing, so a wide cron burns Actions minutes to no effect.
These two schedules have to be moved together. Widen the window in
renovate.json without widening the cron and Renovate simply never runs, with
nothing in the logs to say why. If that bothers you, drop schedule and
lockFileMaintenance.schedule from the config and drive the timing from the
cron alone — one scheduler, at the cost of losing the local-time window.
Once several projects share this setup, move the body of the file into a
dedicated repository and reduce each project to
{"extends": ["github>paolobellini/renovate-config"]} — the policy then lives
in one place and no longer has to be re-copied by preset:install. This is safe
to centralise precisely because the file describes policy, not commands: unlike
the reusable CI workflows, nothing in it can fall out of step with a project's
composer scripts.
Sharding a large suite
--sharded swaps tests.yml for a variant that splits the suite across a
matrix of jobs instead of running it in one. It replaces the file rather than
adding a second one, so the suite is never run twice.
type-coveragestays a single job —--type-coverageis static analysis, there is nothing to split and it needs no database.testsfans out over four shards, each job runningcomposer test -- --shard=<n>/<total>. The total comes fromstrategy.job-total, so widening the matrix line is the only edit needed — the denominator follows on its own.
Pest balances the shards from tests/.pest/shards.json, a file of recorded
per-class timings. Without it, it chunks the test classes evenly and one shard
ends up dominating the wall clock; Pest warns in the run output when the file is
missing entries.
--sharded therefore also installs .github/workflows/update-shards.yml, which
regenerates it every Monday (and on demand via Run workflow): it runs
composer update-shards against the same database service and commits the file
back if it changed. Seed it once locally with composer update-shards so the
first sharded runs are balanced too.
The job needs contents: write and pushes to the default branch, so a branch
protection rule that admits no exception will reject it — either allow the
github-actions[bot] actor or drop the workflow and refresh the file by hand.
Note also that --update-shards writes nothing when the suite fails, and that
GitHub disables scheduled workflows after 60 days without repository activity.
The sharded variant drops the --coverage --min=90 gate: each job only sees its
own slice, so a per-shard minimum is meaningless and the reports would have to be
merged to be comparable. Shard only when the suite is big enough that this is
worth losing.
Branch protection needs updating too — the required check stops being tests
and becomes one entry per shard (tests (1), tests (2), …).
DB_OPTIONS cannot be shared: mysqladmin is absent from the MariaDB image and
Postgres has neither, so the health-check is per engine. Set all four together —
a DB_IMAGE on its own leaves the workflow connecting with the wrong driver and
port.
skills — agent skills
Opt-in, like lefthook: offered in the prompt but never pre-selected, since it
needs npx and the network. It runs the
skills CLI over two sources:
jpcaparas/superpowers-laravel—laravel:queues-and-horizon,laravel:http-client-resilience,laravel:performance-select-columns.mattpocock/skills—wait-whatandteach, both user-invoked only (disable-model-invocation), so they never fire unless you type them.
They were picked to fill the gaps the .ai guidelines leave, not to restate
them. Skills covering form requests, policies, caching, controllers, resources
or testing are deliberately excluded: those are model-invoked, so they would
fire exactly while the agent writes the code your own conventions govern, and
offer a second opinion on top of them.
Two details of the CLI are baked into the command because both fail quietly:
- each skill is named with its own
--skillflag. Written as--skill=namethe filter is ignored and every skill in the repository gets installed. - the agents are named explicitly (
universal,claude-code). Left out, the CLI writes into every agent directory it detects — including the ones theaigroup has just removed.
Skills land in .agents/skills/ as real files, with symlinks from
.claude/skills/. Commit .agents/skills/ and skills-lock.json; the lock
file records a hash per skill, so npx skills update can tell you when a source
has changed upstream.
codegraph — the project's code graph
Opt-in. Two commands, neither asking anything:
codegraph install --yes --target claude --location local
codegraph init --yes
The first wires CodeGraph into the project, for Claude Code only: the MCP
server in .mcp.json, the tool permission and prompt hook in
.claude/settings.json, and a marker-fenced section in .claude/CLAUDE.md.
Nothing is written to your global config, and committing those files gives the
whole team the same setup. The second creates .codegraph/ and builds the
graph, giving the agent a call graph to query instead of grepping for call
sites.
Only the binary is installed once per machine. When it is missing, the
group asks whether to run npm install --global @colbymchenry/codegraph —
declining leaves the machine untouched, and non-interactive runs only print the
command, never run it. The upstream npx @colbymchenry/codegraph wizard is not
used: it asks questions the installer has no terminal to answer.
CodeGraph belongs to the host. Under sail artisan preset:install the group
only says so and stops: run it with plain php artisan instead.
An existing .codegraph/ is not rebuilt unless --force is passed, since
rebuilding an index is not free; the wiring is still refreshed.
Nothing needs adding to .gitignore: CodeGraph writes one inside .codegraph/
that ignores everything but itself, because the index is local to each machine.
Flags
php artisan preset:install --configs --ai --scripts --github # pick groups, skips the steps php artisan preset:install --packages # runtime packages only php artisan preset:install --skills # agent skills only php artisan preset:install --codegraph # build the code graph php artisan preset:install --runtime=local # sail | local, skips the question php artisan preset:install --force # overwrite existing files / deps php artisan preset:install --no-install # skip the auto composer update
Without flags in a non-interactive shell, preset.json is followed when it
exists; otherwise the default groups install: packages, configs, scripts,
ai and github.
Runtime — where the project's commands run
The first thing the installer settles, because the rest depends on it: how
composer require is launched and whether the lefthook jobs carry the
vendor/bin/sail prefix.
| Situation | Runtime | Asked? |
|---|---|---|
No Sail (vendor/bin/sail or a compose file missing) |
local | no |
| Sail configured, installer run on the host | Sail by default | yes |
Installer run inside the container (sail artisan …) |
Sail | no |
--runtime=sail|local answers up front; a non-interactive run takes the default.
Inside the container the runtime is still Sail — the files written for the
project keep the prefix — but the installer's own commands run directly, since
it is already where Sail would have sent them.
Conventions in brief
- Actions pattern: thin controllers — validate (Form Request) → bind →
$action->handle(...)→ Resource. Onefinalaction per write, singlehandle(). - No explanatory comments; PHPDoc only (array shapes / generics).
- Commits:
type(scope): message, all lowercase, subject only. - Tests: Pest, ≥90% coverage. Unit tests assert the object; feature tests
assert the database.
tests/Feature/{Model}/{Method}Test.php. - PHP: strict types,
finalclasses, constructor property promotion, explicit return types, curly braces always.
License
MIT