Back to devlogs
Laravel · FilamentPHP · Plugin

Publishing to Packagist found the paths I never walked

/ / 4 min read

The package could not be installed by anyone, because composer.json sat in a subdirectory. Then v0.1.0 shipped with creating a page from the panel completely broken. Both were paths a new user hits immediately and I never took once, because a seeder did it for me.

A terminal showing a composer install failing, beside a database error about a column that does not exist.
On this page

Atelier worked. Twenty-six tests passing, a full-screen builder, bilingual public pages, images uploading. Then I tried to install it somewhere else and found two things that had been broken the whole time, both on the very first thing a new user does.

Nobody could install it, including me

The repository was laid out as a small monorepo:

Docs/
packages/filament-atelier/     ← composer.json lives here
example/                       ← a Laravel app that installs it

The example app installed it through a composer path repository pointing at the package directory, so everything worked locally and had done for two days.

Composer and Packagist read composer.json from the repository root. There was none. So composer require safi/filament-atelier could never have worked, and neither could installing straight from the GitHub URL. The one setup that worked was the one I had been using, which was the only one that pointed at the subdirectory explicitly.

The fix is that the package becomes the repository. Docs/ and example/ stay as siblings and get export-ignored:

/art                export-ignore
/example            export-ignore
/Docs               export-ignore
/tests              export-ignore

so composer require pulls 170 KB of package and none of the test app. Verified by checking what a consumer actually downloads:

git archive HEAD | tar -t

Then verified properly, by resolving the package from a clean directory somewhere else on disk, which is the check I should have been running from the start.

The tracked node_modules

Same fix uncovered a smaller thing. .gitignore had:

/vendor
/node_modules

Both anchored to the root with that leading slash, which is precisely what the leading slash means. packages/filament-atelier/node_modules matched neither, so about 500 files had been committed without me noticing.

Dropping the slashes fixes it going forward. The files stay in history, which for a repository about to be public is worth knowing even if it is not worth a rewrite.

v0.1.0 shipped with "New page" broken

The package went up on Packagist. I installed it on a different project. Clicked New page. Got this:

SQLSTATE[HY000]: General error: 1
table atelier_pages has no column named slugs

insert into "atelier_pages" ("title", "slugs", "seo", ...)

Slugs live in their own table, because a JSON map cannot carry a unique index and two pages sharing a slug in one locale is a real bug rather than a hypothetical one. But the settings form edits them as slugs.{locale}, so the form data arrives with a top-level slugs key that is not a column.

The edit screen already handled this. It stripped the key before saving and wrote the slugs afterwards:

protected function mutateFormDataBeforeSave(array $data): array
{
    $this->slugsToSave = $data['slugs'] ?? [];
    unset($data['slugs']);
    return $data;
}

The create action did not. So the key went straight into the insert.

Why a passing test suite did not catch it

This is the part worth keeping.

Every page in the example app was created by the demo seeder, which builds models directly. Every test that needed a page did the same. Not one test, and not one manual click in two days of development, ever created a page through the resource's create action.

The path a new user hits within thirty seconds of installing was the only path nobody had taken. It was invisible precisely because the tooling I built to make development convenient, a seeder that gives you three realistic pages instantly, removed my reason to ever use the real one.

The fix is small. Both screens now share one trait:

trait HandlesPageSlugs
{
    protected function pullSlugs(array $data): array
    {
        $this->slugsToSave = $data['slugs'] ?? [];
        unset($data['slugs']);
        return $data;
    }

    protected function applySlugs(Page $page): void
    {
        // Always call it. With no slugs typed, setSlugs() generates
        // them from the title, and a page with no slug is unreachable.
        $page->setSlugs($this->slugsToSave ?: array_fill_keys(
            array_keys(config('atelier.locales', [])), null,
        ));
    }
}

One place, so a third code path cannot forget. Plus three tests: create with slugs, create without, and edit slugs without duplicating rows. The first of those reproduces the exact error before it fixes it.

Released as v0.1.1 within the hour, with a release note that says plainly that anyone on v0.1.0 should update because creating a page was impossible.

The documentation had the same shape of bug

I had written a wiki page specifically so an AI agent could install the package on a fresh project and verify it. Before assuming it worked, I gave the page to a model with no other context and asked what it would do.

It produced a correct install sequence, the right verification commands, and correctly identified all three silent failure modes. It also answered the first question exactly the way a real agent would have:

First command: composer require safi/filament-atelier Would it succeed if the package is not on Packagist? No. Does the page address that? No.

At that point the package was not on Packagist. The installation page mentioned the workaround; the agent quickstart did not, and anyone following only the quickstart would have stopped dead on line one.

Same failure as the other two. I knew the workaround, so I never needed the instruction, so I never noticed it was missing.

What I actually take from this

Three bugs, one shape. The thing that makes development comfortable is the thing that stops you walking the path a new user starts on.

A seeder means never using the create form. Knowing the repository layout means never doing a clean install. Knowing a workaround means never reading your own first instruction.

The cheap defences, in the order they would have helped:

  • Install from a clean directory somewhere else on disk, before publishing rather than after.
  • Have at least one test per resource that creates a record the way the UI does, not the way the seeder does.
  • Hand your setup document to someone, or something, that knows nothing, and watch where they stop.

None of those are clever. All three would have caught a bug that shipped.

FAQ

Frequently asked

Because Composer resolves a package by reading composer.json from the root of the repository it is given, and Packagist does the same when it indexes a VCS URL. A package whose manifest sits in a subdirectory is invisible to both, so neither composer require nor a direct install from the GitHub URL can find it. Only a path repository pointing explicitly at the subdirectory works, which is exactly the setup a monorepo author uses locally, so the problem stays hidden until someone tries to install it properly. For a single-package repository the fix is to make the package the repository and export-ignore the test app and docs so consumers do not download them.

A leading slash in a gitignore pattern anchors it to the directory containing the gitignore file, so /node_modules matches only the one at the repository root. A nested copy inside a subdirectory does not match and gets tracked, which is easy to miss because git status stays quiet once the files are committed. Writing the pattern without the leading slash, as node_modules/, matches at any depth. The files already committed stay in history, so catching this before a repository goes public is worth a moment's attention.

By never using it. Development tooling that makes records easily, a seeder or a factory, removes the reason to ever click the real create action, and tests that need a record use the same shortcut because it is faster. The result is a code path that every new user hits within the first minute and no test or manual session ever touches. The defence is a single test per resource that exercises creation the way the interface does, calling the create action with form data rather than instantiating the model, which catches the whole class of bugs where form fields do not map cleanly onto columns.

Give it to a reader with no other context and ask them to state what they would do, before they do it. A language model works well for this because it can only use what is on the page and will not silently fill gaps from prior knowledge, which is exactly the failure mode of an author reviewing their own instructions. Asking specifically what they would still have to guess at, and which step would fail first, surfaces missing prerequisites quickly. In this case it found that the first command in the guide would fail outright, because the author knew the workaround and had never needed to write it down.

Following along? Start a project.

Start a conversation →