nunomaduro/skeleton-php
A clean starting point for building PHP packages, from Nuno Maduro. It bundles a sensible structure, testing setup, and tooling so you can skip boilerplate and focus on writing your package's actual code.
This is a repository template, not a runtime dependency. There is no composer require step in an application — you start a new package from it. That distinction drives everything else: "using" it day-to-day means you own the code and the tooling, and upstream improvements will never arrive via composer update.
git clone https://github.com/nunomaduro/skeleton-php my-package
cd my-package && rm -rf .git && git init
(composer create-project nunomaduro/skeleton-php my-package also works if you prefer that flow.)NunoMaduro\SkeletonPhp, skeleton-php, SkeletonPhp, nunomaduro. Touch composer.json (name, description, autoload, authors), src/, tests/, phpunit.xml.dist, phpstan.neon, the CS-fixer config, the README badges, and the workflow files.composer install
composer test # or: vendor/bin/phpunit
Where to look first: composer.json is the real entry point — its scripts block tells you exactly how the author expects you to test, lint and analyse. Then read src/ (one example class demonstrating namespace + style conventions) and tests/ (shows the mirror-structure convention). Finally skim .github/workflows/ so you know what will run on your first PR.
First use case: a small, framework-agnostic utility package — a parser, a value-object library, an API client — where you want tests, static analysis and CI green from commit one instead of bolting them on later. If you need a Laravel-aware package, this base still works but you'll add a service provider and Testbench (see below).
Treat composer scripts as your workflow API. Rather than remembering tool flags, standardize on the shipped aliases and add a single aggregator:
"scripts": {
"test": "phpunit",
"analyse": "phpstan analyse",
"lint": "php-cs-fixer fix",
"check": ["@lint --dry-run", "@analyse", "@test"]
}
Then composer check locally, and the same command in CI — one definition of "passing". Add a Makefile or a pre-push git hook that calls it if you want a hard gate.
Mirror src/ in tests/. Keep the skeleton's convention: src/Contracts/Foo.php → tests/Contracts/FooTest.php, same namespace with Tests suffix. It makes "where is the test for this?" a non-question as the package grows.
Choose your integration story up front, because it changes composer.json and tests/ substantially:
src/ free of Laravel/Symfony imports. Put optional integrations behind suggest in composer.json and, if needed, a Bridge/ or Integrations/ namespace that is only autoloaded when the dependency exists.require-dev: orchestra/testbench, extend Orchestra\Testbench\TestCase instead of plain PHPUnit's, register your provider there, and add extra.laravel.providers for auto-discovery. Keep the provider thin — it should only bind, not contain logic.src/Contracts/ and src/Concerns/ early. Retrofitting interfaces after consumers exist is the most common source of breaking changes in small packages.Add a CI matrix early. The shipped workflow typically runs one PHP version. Widen it to PHP versions × dependency versions:
strategy:
fail-fast: false
matrix:
php: ['8.1', '8.2', '8.3']
dependencies: [lowest, highest]
composer update --prefer-lowest is the single most effective way to catch a composer.json constraint that lies about what your code actually supports — and it's cheapest to fix on day one.
Keep dist files lean. The skeleton's .gitattributes already export-ignores tests and CI; verify it covers anything new you add (fixtures, docs, .github/). Consumers installing via --prefer-dist then get only src/.
Release with plain semver tags. No release tooling is shipped, and none is needed. Tag v1.0.0, register on Packagist, enable the GitHub webhook. Because you have zero dependents at the start, this is the cheapest moment to get your public API right.
grep -rniE "nunomaduro|skeleton" . --exclude-dir=vendor --exclude-dir=.git and check README badges, workflow name: fields, phpunit.xml.dist testsuite names, and the CS-fixer header_comment/name if present. A stale NunoMaduro\SkeletonPhp in a test namespace produces a confusing "class not found" rather than an obvious error.vendor/name) and are matched by Packagist literally. PHP namespaces are case-sensitive on Linux CI but often not on macOS — a case mismatch that passes locally will fail in Actions.composer dump-autoload (add -o for optimized production autoloads). If you rely on classmap generation, remember new files need a re-dump.phpunit.xml.dist vs phpunit.xml. The .dist file is committed; a local phpunit.xml should be gitignored and used for machine-specific overrides (coverage drivers, env vars). Don't put your local paths in the dist file.XDEBUG_MODE=coverage in the workflow or install pcov in setup-php. Locally, that same env var is the usual fix.__call, __get) or dynamic properties — you'll hit false positives. Fix them with @method/@property docblocks or a small stub file; resist dropping the level or growing a baseline, both of which quietly stop paying for themselves.require. Adding laravel/framework or symfony/* as a hard runtime dependency collapses your installable audience. Use suggest, conflict, and optional bridges instead.actions/checkout@v2 and an old shivammathur/setup-php may already be deprecated and will warn or fail. Also set coverage: none on the non-coverage matrix jobs to cut CI time roughly in half.composer update --prefer-lowest locally; nine times out of ten it's a minimum-constraint or a polyfill you didn't know you were relying on.CONTRIBUTING.md noting the commit you forked from, and periodically diff against upstream main. Conversely: once you've diverged, delete the upstream badge and the "skeleton" wording from the README so it doesn't read like an abandoned fork.How can I help you explore Laravel packages today?