How I Built My Own Private Composer Package Distribution

When I started selling Laravel Translations Pro, I needed a way to distribute private Composer packages to paying customers. The obvious options were Satis (self-hosted) or Private Packagist (hosted). I built my own instead. Here's why, and how.

Why Not Satis or Private Packagist?

Satis generates a static packages.json file. You rebuild it every time you tag a release, host it somewhere, and bolt on your own authentication. It works, but it's a separate deployment, a separate build step, and it has no concept of licenses or access control.

Private Packagist handles all of this for you — for $49+/month. It's a great product, but I already have a Laravel app that manages licenses, products, and users. Adding another service (and another monthly bill) for something I can build in a few hundred lines of code felt wrong.

What I actually needed was simple:

  1. Serve a packages.json that Composer understands
  2. Serve zip archives for each version
  3. Authenticate requests using the customer's license key
  4. Only return packages the customer has paid for

That's it. My Laravel app already knows who bought what. The missing piece was speaking Composer's protocol.

How Composer Repositories Work

Composer can use any HTTP server as a package source. The protocol is surprisingly simple. You need two endpoints:

  1. /packages.json — A JSON file listing all packages, their versions, and where to download them
  2. A dist URL — Where Composer downloads the actual zip file for a version

When a developer runs composer require outhebox/translations-pro, Composer hits the repository URL, reads the metadata, resolves the version, and downloads the archive. That's the entire contract.

The Implementation

Routes

Three lines in a dedicated route file:

routes/composer.php

Every request goes through ComposerAuthMiddleware first. No valid license, no packages.

Authentication

Composer supports HTTP basic auth natively. When a customer configures their credentials, Composer sends them with every request:

PHP

The license key can be sent as either the username or password — both work. This makes setup easier for customers since different Composer versions handle the fields differently. The validated license is attached to the request so the controller doesn't need to look it up again.

Generating packages.json

The packages.json response is the heart of the repository. Composer expects a specific format: a packages object where each key is a package name, and the value is an array of version metadata.

PHP

The key detail: we merge the package's actual composer.json into each version entry. This means Composer gets the real dependency requirements, autoload configuration, and PHP version constraints — exactly what it would get from Packagist.

The controller queries the customer's licenses to determine which products they've paid for, then passes those product IDs to the service:

PHP

If a customer owns Translations Pro but not kit, they only see Translations Pro packages. The repository is personalized per license.

Serving Package Archives

Instead of storing zip files, I proxy downloads from GitHub. When Composer requests a package archive, the controller verifies the license has access, then fetches the zip from GitHub's API:

PHP

GitHub's zipball endpoint returns a redirect to a temporary S3 URL. I follow that redirect and pass it through to Composer. This means I never store package archives — GitHub handles the storage and bandwidth.

Syncing Versions from GitHub

When I tag a new release on GitHub, the versions need to appear in my repository. The GitHubService fetches tags and creates version records:

PHP

For each tag, it fetches the composer.json at that commit ref and stores it alongside the version. This is what gets merged into packages.json later. The sync is idempotent — running it twice won't create duplicate versions.

The Database Schema

Two tables power the entire system:

Text

The product_id distinction is what separates private and open source packages. If a package has no product linked, it's open source and shown publicly. If it does, it's private and only accessible through the Composer repository with a valid license.

Customer Setup

After purchase, customers see three commands in their dashboard:

Terminal

That's it. Three commands and they're pulling private packages like any other dependency. No special tooling, no custom plugins, no SSH keys to manage.

What I'd Do Differently

Add caching to packages.json. Right now, every composer update triggers a database query. The response rarely changes, so caching it per-license with invalidation on version sync would cut unnecessary load.

Set up GitHub webhooks from day one. I started with manual syncing and added webhooks later. Having versions appear automatically on release saves time and prevents the "I tagged it but customers can't install it" support tickets.

The Takeaway

A private Composer repository is not a complex infrastructure project. It's two endpoints, a middleware, and a service class. If you're already running a Laravel app that handles licenses, you have everything you need. The Composer protocol is simple enough that building your own is faster than configuring and maintaining a separate tool.

Total code: ~200 lines of PHP. Zero additional services. Zero monthly fees.

Related articles