Sharing private PHP packages across projects is a chore: every consuming app needs its own repositories entries in composer.json, its own GitHub or GitLab credentials, and Composer crawls the provider's API repo-by-repo just to resolve versions. Package Pipeline replaces all of that with one self-hosted registry. Point it at your repositories once, and every project can composer require your private packages as if they were on Packagist — one repository URL to configure, no per-repo wiring, and your code never leaves your infrastructure.
How it works, in one pass: you register a package (a repository on GitHub or GitLab) in the Filament admin panel, a sync job reads its tags and branches and stores each one as a version, and Composer clients fetch /packages.json, per-package metadata, and zipball dists straight from the app over the standard Composer v2 repository API. Each version's zipball is downloaded from the provider at sync time and stored on a local or S3 disk with its sha1 checksum, so dist downloads are served entirely from the app's own storage. Authentication against the provider goes through a source (a connected account and its credentials), a per-package token, or — on GitHub only — a global fallback token, whichever is available, in that order.
- PHP 8.3+ with Composer
- Node 22+ with npm, to compile the front-end assets and the admin panel's stylesheet
- SQLite (default — nothing to configure), or MySQL/Postgres if you prefer
- A GitHub or GitLab account with the repositories you want to serve (GitHub Enterprise and self-managed GitLab both work; each source can point at its own API base URL)
Every page the app serves is Filament's, but Filament's shipped stylesheet carries only its own fi-* classes and no Tailwind utility layer. The panel's views use utility classes, so npm run build compiles a theme that has both — see Development. A deploy that skips it serves those views unstyled, without erroring.
Clone and run the one-shot setup script. It installs PHP and Node dependencies, builds the panel stylesheet, creates .env, generates an app key, runs migrations, and seeds the panel's permissions:
git clone https://github.com/AlwaysCuriousCo/package-pipeline.git
cd package-pipeline
composer run setupCreate your admin login. The command prompts for a password, so it never lands in .env, in your shell history, or in version control:
php artisan admin:create --email=you@example.comRe-running it against the same address is safe — it updates that account rather than creating a second one, which also makes it the way to reset a forgotten password. The account is granted the super_admin role, which is what gets it into the panel; see Roles and permissions.
Start everything (HTTP server, queue worker, and log tail, all in one command):
composer run devLog in at http://localhost:8000 — the root URL redirects to the admin panel at /admin.
The fastest way to sync a private repository is a GitHub token. Create a fine-grained personal access token with read-only Contents and Metadata access to the repositories you care about, and set it in .env:
GITHUB_TOKEN=github_pat_...Then in the admin panel go to Packages → New package, paste the repository URL, and follow the wizard — it works out the rest from the repo. Trigger a sync from the package's page (syncs run on the queue, which composer run dev is already processing), or from the CLI:
php artisan packages:sync # sync everything
php artisan packages:sync acme/core # one package, by composer name or owner/repo
php artisan packages:sync --source=acme # only packages under one source
php artisan packages:sync --queue # dispatch to the queue instead of running inlineAn inline packages:sync or package:rebuild stands aside for a package whose sync is already queued or mid-import, rather than writing over it, and says so; run it again once that one lands.
Once a package has synced, its versions, release heatmap, and any sync errors all show on its admin page.
A repository that holds several packages — a monorepo — is added once per package, each with its Subdirectory filled in. See docs/monorepos.md.
GITHUB_TOKEN is deliberately a last resort — it's a person's credential with broad reach. For anything beyond a first spin, connect a source instead (next section) and clear the global token.
A source is a GitHub organisation or user connected through a GitHub App: tokens expire hourly, access is scoped to exactly the repositories chosen at install time, and the credential belongs to the org — it doesn't break when a person leaves. Registering the app is a one-time, ~5 minute job per deployment; the full walkthrough (including the non-obvious permission gotchas) is in docs/github-app.md.
Once the app is registered and GITHUB_APP_ID / GITHUB_APP_PRIVATE_KEY are set, connecting an organisation is one click from Sources in the admin panel. Packages are linked to their source automatically by repository owner.
GitLab works the same way from the registry's side — sync, versions, dists, webhooks, artifact uploads are all provider-agnostic — but it is connected by hand rather than by an install click, because GitLab has no App to install.
Go to Sources → New source and fill in:
| Field | Value |
|---|---|
| Provider | GitLab |
| Organisation or user | The top-level namespace: the group or username your projects sit under (acme). Packages under that namespace are linked to this source automatically. |
| API base URL | Leave empty for gitlab.com. For a self-managed instance this is the v4 API root, not the web host: https://gitlab.example.com/api/v4. |
| Access token | A GitLab access token (see below). Stored encrypted. |
Then Test connection on the source, which lists the projects the token can reach and records the count.
Which token, and what it needs. The registry sends it as PRIVATE-TOKEN, so anything GitLab calls an access token works: a group access token is the closest analogue to a GitHub App installation — it belongs to the group rather than to a person, so it survives someone leaving — with a project access token or a personal access token as the alternatives.
| You want | Scope | Role |
|---|---|---|
| Sync versions and serve dists | read_api |
Reporter or above |
| That, plus webhooks created for you | api |
Maintainer or above |
read_api is enough for everything except creating the webhook: that is a POST /projects/:id/hooks, which GitLab does not allow a read-only scope to make and does not allow below Maintainer. A token with only read_api still gives you a working package — it just syncs when asked rather than when pushed, and the package's page says so.
What differs from GitHub. Worth knowing before you plan around it:
- There is no account-wide webhook. The GitHub App has one webhook covering every repository in every installation; GitLab has no equivalent, so every GitLab package carries a hook on its own project. See docs/webhooks.md.
- Deliveries are authenticated by a replayed secret, not an HMAC signature — GitLab sends the hook's own token back in a header. Same practical effect, different failure modes; again, docs/webhooks.md.
- The credential is long-lived. No hourly, automatically-rotated installation tokens. Give the token an expiry in GitLab and put its renewal somewhere you will see it.
GITHUB_TOKENis not a fallback for GitLab. It is a GitHub credential and is deliberately never handed to another provider's API, so a GitLab package needs either a connected source or a token of its own.- Nested namespaces are fine.
group/subgroup/projectis handled; the path is what GitLab calls the project'spath_with_namespace.
A package can also be added without a source at all, by pasting a GitLab URL into Packages → New package and putting a token in the package's own Access token field, which is used for whatever provider the URL names.
Access to the admin panel is controlled by Filament Shield, which layers roles and per-resource permissions over spatie/laravel-permission. Manage them under Shield → Roles in the panel.
Two rules are worth knowing:
- A user with no role cannot reach
/adminat all. An account existing is never by itself a way in, so a leftover user row is harmless. - Accounts are best provisioned by invitation. Users → Invite user creates the account holding a random password nobody ever learns and shows a single-use setup link, which you deliver yourself — nothing is emailed, exactly as
user:addbehaves on the console. The link lives five minutes; Password link on any row issues a fresh one. super_adminis an ordinary role that holds every permission, not a gate that skips the checks. What is ticked in the Roles screen is exactly what the role can do, which keeps access auditable — but it also means the role knows nothing about permissions created after it was granted.
The permissions themselves are seeded from the panel's own resources, pages and widgets, so a fresh database gets them from php artisan db:seed (which composer run setup already runs).
Audit log is the one resource whose permissions nothing else narrows. A role that can view it reads every change recorded anywhere in the registry — private package names and their VCS URLs, every account's name and email, every access token's name, prefix and abilities, and the before-and-after values of records the role cannot open on any other screen. Grants, team membership and Unscoped:Package make no difference to it.
It is not scoped because a scoped audit trail is a misleading one: the rows hardest to attribute to a viewer are the deletions, which leave no record to check a policy against, and an investigator reading a silently filtered history would believe it complete. Read the permission as "may read the whole registry's history", and grant it to the same people you would let see the whole registry. The Roles screen says so too.
Nothing in the panel can add, edit or remove an entry — the policy refuses those outright, whatever a role holds. That is the whole of the guarantee: the rows are ordinary rows in activity_log, kept for two years and then pruned, with no hash chain or signature over them. Anyone with database or shell access can rewrite them, which on a single-box install is the same person who administers the panel. If you need the log to survive its own administrator, ship it somewhere they cannot reach — a syslog collector or an append-only store — rather than relying on the table.
Row-level access can be granted to a team rather than to a person: a team holds package and repository grants, users belong to it, and a user's effective access is their own grants plus their teams'. Onboarding becomes adding somebody to a team instead of repeating the last person's grants.
Teams only ever widen what an account can see, and removing somebody from one takes back only what that team gave. A user in no team is unaffected. See docs/teams.md.
Access Management → Audit log records who changed what: packages created, renamed, abandoned and deleted; access tokens issued, rolled and revoked; users created and deleted and their roles granted and taken away; sources connected and disconnected; repositories and security advisories. Entries carry the signed-in user where there was one, and read as "System" for changes made by the console, a webhook, or the scheduler.
Two things it deliberately does not do:
- It never records a secret. Each model states an allowlist of attributes worth attributing a change to, and that allowlist is filtered again against anything encrypted, hashed, or named like a credential — so a token, a client secret or a password hash cannot reach the table even if somebody adds it to a list. See
App\Models\Concerns\LogsAuditableChanges. - It cannot be edited, by anybody. The panel offers no create, edit or delete, and
ActivityPolicyrefuses all three whatever a role holds. A log the people it records can tidy up is not evidence of anything.
Entries are kept for two years and then removed by the nightly model:prune.
When you add a resource, page, or widget, generate its permissions and hand them to the super admin:
php artisan shield:generate --all --panel=admin # permissions + a policy per model
php artisan admin:create --email=you@example.com # re-syncs the role to every permissionSkip the second command and you will find yourself locked out of the resource you just added. Other roles keep whatever they had — tick the new permissions for each role that should have them.
shield:generate writes policy classes into app/Policies/, which belong in version control. Deployments only need the database rows, so they run the seeder instead and never generate code.
In any consuming project:
composer config repositories.private composer https://packages.example.com
composer config http-basic.packages.example.com token pp_your-token
composer require acme/coreComposer will resolve versions from the registry and download dists through it; every zipball is served from the archive stored at sync time (and verified against its published shasum), so the provider is never in the download path. composer audit works too — the registry answers the advisory endpoint for the packages it serves, from advisories recorded against them in the panel.
Each package's admin page prints both configuration lines with its own name and repository URL already filled in.
Every Composer endpoint is behind an access token: /packages.json, /search.json, /list.json, /p2/..., /dist/..., /security-advisories, and the artifact upload endpoint. The token travels as the HTTP Basic password (the username is ignored) or as a bearer token, and a request without one is answered 401 with a WWW-Authenticate challenge — which is also what makes an interactive composer install stop and ask for credentials rather than fail obscurely.
Tokens come in two kinds, and the difference is what they can see:
- Personal tokens, issued by each panel user from API tokens in the user menu. A personal token sees exactly what its owner sees, so revoking a person's panel access revokes their Composer access with it.
- Deploy tokens, created under Deploy tokens in the sidebar. These are machine principals with no user behind them, for CI. A deploy token sees the repositories and packages it was granted — or everything, if it was granted nothing at all, which is worth knowing before you create one and walk away.
Either kind carries abilities, and can be given an expiry. Two families, and none of them implies another: repository:read (install packages) and repository:write (publish artifact uploads) cover the Composer protocol; api:read, api:write and api:delete cover the management API. A token pasted into every developer's auth.json so composer install works should not, by that act, be able to delete a package — which is what keeping them apart buys.
The plain token exists only at the moment it is issued; the row keeps its sha256 and a short prefix, so a lost token is replaced rather than recovered. Revoking is a soft delete, which keeps the audit trail of what the token was and when it was last used.
Failed authentications are rate limited per address (30 a minute), and the 429 that follows says in as many words that it is a rate limit rather than a rejected token — because the place that message gets read is a CI log.
A repository here is a Composer repository this registry serves. Every package lives in one, and can be served from any number of others. The default repository answers at the site root; every other one you create is mounted under /r/{path}, so a single installation can serve independent registries — a public one and an internal one, say — with independent access rules.
Whether a repository can be read without a token is the public flag on it. Repositories you create are private.
Important
The default repository is created public. It stands in for the whole registry as it behaved before repositories and tokens existed, and packages created without choosing a repository land in it — so on a fresh installation, anyone who can reach the app can list and install everything in it. Open Repositories → Default and turn Public off (or move the packages to a repository of your own) before the app is reachable from anywhere you don't control.
A presented token is always checked, public repository or not. A CI system holding a revoked token hears about it as a 401 rather than continuing to work by accident until someone makes the repository private.
A package lives in one repository and can be added to as many others as you like — the same package, one sync, one set of archives, answering under every mount that serves it. Use Also served from on the package's form, Serve an existing package on a repository's, POST /api/v1/packages/{id}/repositories, or php artisan package:serve acme/widgets internal.
Access is decided by the mount rather than by the package: a package added to a private repository is private there even if it is public where it lives — and, the edge worth knowing before you use this, adding a package to a public repository publishes it. Publishing stays with the repository the package lives in; a share serves what a package publishes and confers no right to publish into it. Two packages cannot share a name in one repository, so a repository already answering for the name refuses.
The same deployment also answers as an npm registry and a PEP 503 Python package index — one URL and one set of tokens for composer, npm and pip, on the same repository mounts with the same visibility rules:
npm config set @acme:registry https://packages.example.com/npm/
pip config set global.extra-index-url https://__token__:pp_your-token@packages.example.com/pypi/simple/Both surfaces are publish-only (npm publish, twine upload) — CI pushes what it built, as with Composer artifact uploads — and both can mirror their public registries: an upstream carries an ecosystem, so a repository can serve npmjs.org and pypi.org through the same cache-on-demand machinery, under the same local-package-always-wins rule, that mirrors packagist.org. See docs/ecosystems.md for configuration, publishing, mirroring, and what is deliberately not implemented yet.
Each repository can reserve vendor prefixes — acme, meaning every acme/… name. Only that repository may then introduce a package under it, whether through the panel, the API, package:add, an artifact upload, or a sync adopting the name a repository's composer.json declares. Packages published under the vendor before it was reserved keep working; a reservation governs what may be introduced.
This is the server half of a dependency-confusion defence, and on its own it is the smaller half. A project that lists this registry alongside packagist.org will resolve a private name from whichever repository answers for it, and only that project's composer.json can settle that. docs/dependency-confusion.md has the configuration to hand consuming projects, and why each part of it is there.
A repository can be given one or more upstreams — packagist.org, a corporate proxy, another private registry — and will then answer for packages it does not publish, caching the metadata and the release zip on your own infrastructure. One URL resolves a project's whole dependency graph, and a build stops depending on packagist.org and GitHub being up. Nothing is bulk-imported: a package is fetched the first time somebody asks for it, and every install afterwards is served from here.
It is off until an upstream is added, and an installation with none behaves exactly as it always has.
A local package always wins, unconditionally: a name published anywhere in this installation, or under a reserved vendor, is never served from an upstream — visible to the caller or not. That is what keeps mirroring from becoming the dependency-confusion hole it would otherwise be, and it is why reserving your vendor prefixes belongs before turning mirroring on.
Two things to know before enabling it. The disk grows without a ceiling of its own, so mirror:prune — nightly, retention measured on last use — is what bounds it. And a public mirroring repository is an open proxy: Composer read endpoints carry no rate limit, so an anonymous caller can make the app fetch and keep archives. Keep mirroring on private repositories, or rate-limit the public mount at your proxy.
docs/mirroring.md covers all of it: what consumers see, the freshness and failure behaviour, the access-control rule and its sharp edges, and what it costs in disk.
A package or a repository can publish a page anyone can read — no account, no token, no Composer. It shows what the package is, its README.md (or a package-page.md written for the purpose) out of the repository, the install commands, and — if you switch it on — a download for the latest release or for every version.
The URL you hand a project is the first thing somebody pastes into a browser, and before this it answered a redirect to a login form for an account they do not have. Now the default repository can answer a landing page there instead, and each package can answer one at /p/vendor/name.
Off until you enable one, per package and per repository. A page on a private repository still describes the package and shows its version history, but withholds the archives and the install commands and shows an access notice in their place. Images in the README are re-served by the registry using the package's own credentials, which is what makes a private repository's screenshots — and its social card — render for a reader who has no access to it. Every page carries Open Graph and Twitter card tags, JSON-LD and a canonical URL, and /sitemap.xml lists them.
This repository ships package-page.md as a worked example of a page file.
docs/public-pages.md covers all of it: enabling a page, where its content comes from, how untrusted markdown is rendered, what private packages withhold, and the SEO and social-preview tags.
Everything lives in .env, and .env.example carries the same notes in situ. Below are the knobs that belong to this app rather than to a stock Laravel one; the stock ones that matter most in production — QUEUE_CONNECTION, CACHE_STORE, FILESYSTEM_DISK, AWS_* — are covered under Recommended drivers.
Sources and syncing
| Variable | Purpose |
|---|---|
GITHUB_APP_ID / GITHUB_APP_PRIVATE_KEY |
The GitHub App that powers sources. The key takes a path to the .pem or the key itself with \n-escaped newlines. |
GITHUB_APP_WEBHOOK_SECRET |
The secret set on the app's own webhook — this is the switch that turns account-wide auto-sync on. With it, a push to any repository in any installation syncs that package straight away; without it, deliveries to /incoming/github are refused 503 and packages fall back to hooks of their own. Must match the value set on the app. See docs/webhooks.md. |
GITHUB_APP_SLUG / GITHUB_APP_API_URL |
Normally read from GitHub automatically; only set to skip that lookup or on GitHub Enterprise. |
GITHUB_TOKEN |
Last-resort token, used only for packages with neither a connected source nor a token of their own. Deliberately GitHub-only: it is never handed to another provider's API. Leave it empty once sources are set up. |
GitLab needs no environment variables at all — a GitLab source carries its own token and API base URL on the source record. See Connect a GitLab source.
Storage and limits
| Variable | Purpose |
|---|---|
DIST_DISK |
Disk where version archives (Composer zipballs) are stored at sync time. Defaults to FILESYSTEM_DISK; set to s3 on any deployment whose containers don't share a local disk. Must be case-sensitive — S3 and Linux are, a local disk on macOS or Windows is not, and archives:clean reads the difference as orphans. See The dist disk has to be case-sensitive. |
ARTIFACT_UPLOAD_MAX_MB |
Largest artifact zip POST /upload/{vendor}/{package} accepts, in megabytes (default 100). PHP's upload_max_filesize and post_max_size have to allow the same size, or PHP discards the body before the app sees it. |
METADATA_CACHE_DAYS |
How long a rendered /p2 payload is kept (default 7). Entries are keyed by a fingerprint of the versions behind them, so they supersede themselves rather than needing to be cleared — this only bounds how long the leftovers linger. |
METADATA_CACHE_MAX_KB |
Largest rendered payload worth storing (default 4096). A bigger one is served from the version rows every time, which for a package that fat is the lesser problem. 0 turns the cache off entirely. |
Upstream mirroring
None of these turns mirroring on — that is a per-repository decision made in the panel, and an installation with no upstreams never reaches any of it. These are the numbers that apply once one has. Full explanations in docs/mirroring.md.
| Variable | Purpose |
|---|---|
MIRROR_RETENTION_DAYS |
How long since it was last served a cached document or archive survives mirror:prune (default 30). The knob that bounds what the mirror costs in disk, since nothing else ever deletes one. |
MIRROR_METADATA_TTL_MINUTES |
How long a cached upstream document is served before the next request revalidates it (default 60). Expiry costs a conditional round trip, not a download. |
MIRROR_MISSING_TTL_MINUTES |
The same for a name the upstream does not have (default 10). Much shorter, because a package published a minute ago is the one somebody is waiting on. |
MIRROR_ADVISORY_TTL_MINUTES |
How long an upstream's composer audit answer is reused (default 10). |
MIRROR_FAILURE_BACKOFF_MINUTES |
How long an upstream that has just failed is left alone, serving only what is already cached (default 5). Without it an outage costs every lookup a connect timeout. |
MIRROR_MAX_ARCHIVE_MB / MIRROR_MAX_METADATA_KB |
Ceilings on what will be cached from an upstream (default 256 / 8192). ARTIFACT_UPLOAD_MAX_MB bounds what one of your own tokens may spend; these bound what a stranger's published package can. |
Public pages
None of these publishes anything — a page is a per-package or per-repository decision made in the panel. These apply once one is enabled. Full explanations in docs/public-pages.md.
| Variable | Purpose |
|---|---|
PAGE_IMAGE |
Social preview image used by every page that has not set one of its own — an absolute URL or a path relative to the app root, around 1200×630. Worth setting: a link with no card beside it is a link most people do not click. |
PAGE_SITEMAP |
Publish /sitemap.xml and an indexing /robots.txt (default true). false leaves pages reachable and unlisted, and answers robots.txt with a blanket disallow. |
PAGE_MARKDOWN_CACHE_MINUTES |
How long a rendered page body is reused (default 1440). Keyed by a hash of the markdown itself, so a sync that changes the file changes the key. 0 turns it off. |
PAGE_MAX_BODY_KB |
Largest page body stored or rendered (default 512). The body comes out of somebody else's repository; a file orders of magnitude past this is a generated artifact or a mistake. |
PAGE_ASSET_CACHE_MINUTES |
How long an image fetched out of a package's repository is kept (default 1440). A page's screenshots are re-served by this app rather than linked to the provider — the only way a private repository's images render for a reader who has no credential for it — so without a cache every visitor costs a provider request. |
PAGE_MAX_ASSET_KB |
Largest image served or cached (default 4096). |
Queue timing
| Variable | Purpose |
|---|---|
DB_QUEUE_RETRY_AFTER |
Seconds before the queue treats a job as abandoned and hands it to another worker (default 330). Must stay above the longest job timeout (300s, a version import streaming a large archive) or a slow import runs twice at once, downloading and storing the same archive twice. Only raise it. REDIS_QUEUE_RETRY_AFTER is the same knob for a Redis queue, with the same default and the same rule. |
Notifications
| Variable | Purpose |
|---|---|
SLACK_BOT_USER_OAUTH_TOKEN |
Slack bot token (xoxb-…). Published releases and failed syncs are announced there on top of the panel's own notification bell. |
SLACK_BOT_USER_DEFAULT_CHANNEL |
The channel to post in (#releases). Both variables are needed; leave either empty to skip Slack entirely. |
MAIL_ADMIN_NOTIFICATIONS |
Also email the announcements to every panel user holding a role (default false). Needs a real mailer set up first — under the default MAIL_MAILER=log an enabled fan-out writes to the log and delivers nothing. Each user can then opt out for themselves on their profile page. See Emailing the announcements. |
MAIL_MAILER / RESEND_API_KEY |
The mailer itself, log by default. Resend is installed and wants only these two, with MAIL_FROM_ADDRESS on a domain verified in that account; ses, postmark and smtp are configured and work the same way. See Mail is off by default. |
The panel is the usual way in, but everything an operator needs can be done without a browser — which is what makes this deployable to a platform whose only interactive surface is a command runner.
Packages and versions
| Command | What it does |
|---|---|
packages:sync [name] |
Sync versions from their sources. Takes a composer name or owner/repo; --source= narrows to one source, --queue dispatches instead of running inline. The scheduler runs --queue hourly. |
package:rebuild [name] |
Re-import every version, trusting nothing already stored. The recovery path for corrupted archives or metadata drift — reach for it when a sync says everything is current but the output isn't. |
package:add <url> |
Create a package from a VCS repository URL and queue its first sync. --name=, --repo= (which Composer repository to serve it from), --subdirectory= (for a monorepo), --token=, --no-webhook, --no-sync. The scriptable equivalent of the create wizard. |
package:serve <name> <repo> |
Serve an existing package from another Composer repository — root names the registry root. --remove stops serving it there; --home= disambiguates a name that lives in more than one repository. See docs/shared-packages.md. |
package:delete <name> |
Delete a package, its versions and its stored archives. --repo= disambiguates a name served in more than one repository; --force skips the confirmation. |
Archives
| Command | What it does |
|---|---|
archives:clean |
Delete stored archives no version references. Re-synced versions leave their previous archive behind by design and nothing else removes one. --dry-run lists without deleting. Runs nightly. |
archives:audit |
The other direction: find versions whose archive is no longer on the dist disk and clear the reference, so the next sync downloads it again. Refuses when the loss is large enough to read as a wrong or half-restored disk rather than lost files, and never clears a version no sync can rebuild. --dry-run reports without touching; --force is for when the loss really is that big. Run it by hand after restoring a bucket. |
sbom:export |
A CycloneDX bill of materials, streamed. --package= (with --repository= when the name is ambiguous) narrows it to one package, --path= writes to a file or a directory instead of stdout. |
downloads:recalculate |
Rebuild the denormalized total_downloads counters from the raw downloads rows plus whatever downloads:prune has already tallied. For when the counters and the chart disagree. |
downloads:prune |
Delete download rows past DOWNLOAD_RETENTION_DAYS (400), counting them into the totals first so the counters and downloads:recalculate still answer with a lifetime figure. The only bound on the largest table in the schema. --days= overrides the window, --dry-run reports. Runs nightly. |
Accounts and access
| Command | What it does |
|---|---|
admin:create --email= |
Create or update an admin account and give it every permission. Prompts for a password when a terminal is attached; --link (and any non-interactive runner) prints a sealed, single-use setup link instead. Re-run it after adding a Filament resource so super_admin picks up the new permissions. |
user:add [email] |
Create an ordinary panel user and print their password setup link. --name=, --role= (repeatable; roles must already exist). |
user:reset-password [email] |
Print a fresh single-use password link for an existing user. The recovery path when someone is locked out and there is no mail configured. |
token:add <name> |
Issue an access token. --user= for a personal token, --deploy= for a deploy token (created if it doesn't exist), --ability= (repeatable, read by default; read and write are the Composer abilities, or name one in full — api:read, api:write, api:delete), --expires-days=. Prints the plain token once. |
token:revoke <prefix> |
Revoke a token by the prefix shown in listings (pp_ab1cd). What you run when a credential leaks and you have only the log line naming it. |
php artisan shield:generate --all --panel=admin and php artisan db:seed --force round these out — see Roles and permissions.
composer test # phpunit via artisan test
composer lint # code style, report only (Laravel Pint)
composer analyse # static analysis (PHPStan via Larastan, level 6)
vendor/bin/pint # apply the fixes `composer lint` reports
npm run build # compile the panel stylesheetCI runs the first three on every pull request, so a branch that passes them locally is a branch that goes green.
composer run dev runs the web server, a queue worker, pail log streaming, and the Vite dev server together in one terminal — if you run pieces manually instead, remember the queue worker, or panel-triggered syncs will sit in the jobs table forever. php artisan dev:list shows what it starts; the equivalent four terminals are:
php artisan serve
php artisan queue:listen --tries=1 --timeout=0
php artisan pail --timeout=0
npm run devThe admin panel loads resources/css/filament/admin/theme.css, compiled by Vite and registered with ->viteTheme() in AdminPanelProvider. It replaces the app.css that filament:upgrade publishes rather than loading beside it, and it exists because Filament's shipped build has no Tailwind utility layer — only the fi-* classes Filament's own views use.
Tailwind generates utilities only for files named by the @source globs in that file, which currently cover app/Filament/ and resources/views/filament/. A Blade view outside those paths that uses a utility class renders unstyled — no build error, nothing in the browser console, just a div that ignored grid and p-4. Add an @source line when you add a view root.
Neither npm run dev nor npm run build is optional in a fresh checkout. ->viteTheme() resolves the stylesheet through Laravel's Vite helper, which throws ViteManifestNotFoundException when there is no public/build/manifest.json and no dev server running — so every admin page returns a 500 rather than rendering unstyled. That is the loud failure; the quiet one is the @source glob above.
The short version is below. docs/deployment.md is the long one: which queue and cache drivers to run and why the defaults stop scaling, what has to be shared once there is more than one container, what /up does and doesn't prove, and — the part worth reading before you need it — how to back up the database and the dist disk so that a restore is coherent, and how to repair the drift when it isn't.
-
Run a queue worker under a supervisor (
php artisan queue:work --timeout=310 --memory=256 --max-time=3600, withRestart=alwaysorautorestart=true) — package syncs from the admin panel are queued jobs. Keep that timeout between the longest job's own (300 seconds, for a version import streaming a large archive) and the connection'sretry_after(330): a worker that gives up sooner kills healthy imports, and aretry_afterthat fires first hands a still-running import to a second worker, which downloads and stores the same archive again. RaisingImportVersion::$timeoutmeans raising both. The worker is meant to exit — on its memory ceiling, on--max-time, onqueue:restartduring a deploy — so something has to start it again, and unattended it is the most common way this deployment quietly stops syncing. docs/deployment.md has systemd and Supervisor units. -
Run the scheduler (
php artisan schedule:work, or a* * * * * php artisan schedule:runcron entry).routes/console.phpships the maintenance schedule;php artisan schedule:listshows it:Task When Why packages:sync --queuehourly Releases arrive by webhook, so this is not the normal path. It is what covers packages whose webhook registration failed or was never made, and what makes a partial sync's "the next sync will retry them" true. It is cheap: the tag and branch listings are asked conditionally on both providers, so an untouched repository answers 304 Not Modified(which GitHub does not charge against the rate limit at all), and a ref whose sha hasn't moved is skipped without an API read or a download — a routine run fans out no import jobs.archives:clean03:10 Re-synced versions leave their previous archive behind by design and nothing else deletes one. archives:audit03:20 The other direction: a version row can outlive its archive (storage loss, a bucket restored from an older snapshot), and nothing in the request path notices — /p2keeps advertising the version whiledist404s. Syncs deliberately don't check per version, which on S3 was a HEAD request per version per hour; this checks the whole registry with one listing and clears what it can't find, so the next sync downloads it again. It runs unattended, so it refuses at scale: a run where more than ten percent of the versions checked have no archive is reading a disk that is wrong, not a registry that lost that many files, and it says so instead of clearing them.model:prune(notifications, audit log)03:30 One row per admin per event, kept 30 days once read and 90 days unread; audit entries kept two years. queue:prune-batches03:40 One row per sync, kept 48 hours (72 unfinished). cache:prune03:45 The database cache store expires an entry only when its key is next read, and this app supersedes entries rather than invalidating them — so what it leaves behind are rows nothing will ever ask for again. A no-op on redis.downloads:prune03:50 The largest table in the schema, one row per zip served. Rows past DOWNLOAD_RETENTION_DAYS(400) are counted into the packages' and versions' totals and then deleted, sototal_downloadsanddownloads:recalculatestill answer with a lifetime figure.0keeps everything.Every task is
onOneServer(), which needs a shared cache store that supports locks — the defaultdatabase, orredis.CACHE_STORE=filedefeats that once there is more than one container, each holding its own lock;CACHE_STORE=arraydefeats it on any deployment, becauseschedule:runis a fresh process every minute and an in-memory lock does not outlive it. That is a correctness problem rather than a performance one — concurrent rebuilds of one package end with the loser's prune deleting the winner's rows.php artisan aboutreports which you have under Registry → Scheduler locking; see The cache store is not just a cache. -
Build the panel stylesheet with
npm ci && npm run build, on every deploy.public/buildis not in version control, and->viteTheme()throwsViteManifestNotFoundExceptionwhen the manifest is missing — so a deploy that skips this answers 500 on every admin page while the Composer endpoints carry on working normally. See The panel stylesheet. -
Seed the permissions with
php artisan db:seed --force, after migrating and on any deploy that adds a resource. Shield's policies check permissions that must exist as rows in the database; without them the panel denies everything, super admin included. -
Create the first admin account with
php artisan admin:create --email=you@example.com. A command runner with no terminal attached (Laravel Cloud's, a deploy hook) can't prompt for a password, so the command prints a sealed, single-use link that sets one in the browser instead — no password in the environment, and none in the provider's command log. The link expires after 5 minutes; re-run the command for a fresh one. It needs no mail configuration. -
Set
DIST_DISK=s3(and theAWS_*variables) whenever app containers don't share a filesystem, so every instance sees the same stored archives. On Laravel Cloud, attaching an object storage bucket injects theAWS_*values automatically. Downloads are then redirected to short-lived pre-signed URLs rather than streamed through PHP, so the bucket's endpoint has to resolve from wherevercomposer installruns — an internal-only hostname (a MinIO service name, say) breaks clients that the app itself can reach the bucket from. -
Register a separate GitHub App per environment — an app's Setup URL points at exactly one deployment. See docs/github-app.md.
-
Point health checks at
/up, but know what it answers for: the container is up and the framework boots. It runs with no middleware and touches neither the database nor the queue, so it will report a healthy container that has not synced anything in a week. See Health and monitoring for what to watch instead. -
Back the dist disk up before the database, not after. The two hold one dataset between them, and only one of the two inconsistent orderings is harmless. Backup and restore explains why, and what repairs the drift.
Laravel Cloud runs this app well with almost no configuration. Create the application from your fork of this repository, then:
-
Attach a database. App containers are ephemeral, so the SQLite default won't survive a deploy — attach a Cloud database (MySQL or Postgres) from the environment's Resources and let Cloud inject the
DB_*variables. -
Attach an object storage bucket and set
DIST_DISK=s3. Cloud injects theAWS_*variables when the bucket is attached; without it, stored version archives vanish on every deploy and Composer downloads 404 until the next sync rebuilds them. -
Set the build command (Deployments → Build Commands) so the panel stylesheet is compiled into the image:
composer install --no-dev && npm ci && npm run build
Cloud does not detect
package.jsonand add the Node step for you on a PHP runtime — the build command is whatever you set it to, and an application created before this repository had apackage.jsonwill not have one. Thenpmhalf is not optional: without it every admin page answers 500 withViteManifestNotFoundException.It has to be a build command, not a deploy command. Cloud discards filesystem changes made by deploy commands, so a build run there writes
public/build/and then throws it away — the same 500, on every deploy, no matter how many times you run it. -
Add the deploy commands so migrations and Shield's permission rows are in place before anyone logs in:
php artisan migrate --force php artisan db:seed --force
Nothing else belongs here. In particular Cloud documents
php artisan optimize:clearas a command that causes unexpected behaviour around the queue, andqueue:restartis already handled for you after each deployment. -
Add a queue worker to the environment (Resources → Queue Worker, default queue) and give it a 310-second timeout, for the reason above. Package syncs triggered from the admin panel are queued jobs — without a worker they sit in the
jobstable forever. -
Add a scheduler to the environment (Resources → Scheduler) so the maintenance tasks above actually run. Cloud's scheduler invokes
schedule:runfor you. -
Set the GitHub credentials in the environment's variables:
GITHUB_APP_IDandGITHUB_APP_PRIVATE_KEYfor sources (paste the key with\n-escaped newlines), or aGITHUB_TOKENto get started quickly.
Once the first deploy is green, open the environment's Commands panel and run:
php artisan admin:create --email=you@example.comCloud's command runner has no terminal attached, so the command doesn't prompt for a password. Instead it prints a sealed, single-use link to the panel's password-reset screen — open it in your browser and set the password there. The password never passes through Cloud's environment variables or command log, and no mail configuration is needed.
Three things to know about the link:
- It expires 5 minutes after being printed (deliberately short, since the URL lands in the command log). If it goes stale, just re-run the command — it updates the existing account and prints a fresh link, which also makes it the recovery path for a forgotten password.
- It is single-use — once the password is set, the link is dead.
- It carries nothing readable. The address, the reset token and the expiry are encrypted with
APP_KEYinto one opaque path segment, so the URL has no query string at all. That is deliberate:?email=…&token=…arriving at a page full of password inputs is the shape of a credential-harvesting kit, and Chrome's Safe Browsing will put a "Dangerous site" interstitial in front of it on a domain that has no reputation yet. RotatingAPP_KEYinvalidates any link already printed.
After adding new Filament resources, re-run both php artisan shield:generate --all --panel=admin (locally, committing the generated policies) and php artisan admin:create --email=you@example.com (on Cloud) so the super_admin role picks up the new permissions — see Roles and permissions.
- docs/api.md — the
/api/v1management API: authentication, abilities, every endpoint, and a release pipeline end to end. - docs/billing.md — selling subscriptions that grant package access: plans, Stripe checkout, the public pricing page, the customer area, lapse behaviours and version ceilings, and the Manual merchant for comps and purchase orders.
- docs/dependency-confusion.md — reserving vendor prefixes here, and the Composer configuration each consuming project needs so a public package cannot win a private name.
- docs/deployment.md — production drivers, scaling, monitoring, and backup and restore.
- docs/download-analytics.md — exporting download statistics as CSV, from the panel or the shell, per package or registry-wide.
- docs/ecosystems.md — serving npm and Python packages beside Composer: client configuration, publishing, name rules, and what is not implemented yet.
- docs/github-app.md — registering the GitHub App and connecting sources, including troubleshooting.
- docs/merchant-drivers.md — adding a payment merchant other than Stripe: the driver contract, the rules a translation must keep, and what shared machinery a driver inherits.
- docs/licensing.md — the license report, what a version declaring none means, and the CycloneDX SBOM export: its shape, the choices behind it, and what was verified against the spec.
- docs/metrics.md — the Prometheus endpoint: what it exposes, why it is off by default, what to alert on, and what a scrape costs.
- docs/monorepos.md — publishing several packages from one repository: the subdirectory field, how a dist for part of a repository is built, and what a push to a monorepo syncs.
- docs/mirroring.md — serving packagist.org's packages through this registry: enabling it, what consumers see, failure behaviour, and what it costs in disk.
- docs/outgoing-webhooks.md — telling a deploy pipeline or a non-Slack chat tool that a version published or a sync failed: the events, the payloads, and how to verify a signature.
- docs/public-pages.md — publishing a readable page for a package or a repository: the toggles, where the content comes from, what a private package withholds, and the social-preview and search tags.
- docs/shared-packages.md — serving one package from several Composer repositories: how to, what decides access under each mount, and what stays with the repository the package lives in.
- docs/teams.md — granting access to a group rather than a person: what a team holds, how effective access composes, and what it costs on the Composer hot path.
- docs/webhooks.md — auto-syncing on push: the two GitHub delivery paths, GitLab's per-project hooks, and how to tell whether a package is actually covered.
- CHANGELOG.md — what changed in each release and what it asks of the operator.
- docs/packistry-feature-analysis.md — feature comparison against Packistry that informs the roadmap.
Open-sourced under the MIT license.