July 14, 2026

Component packages: ship a Lattice component, not a release pipeline

By Manuel Christlieb — Staff Engineer

Part 10 of the Building Lattice series. Part 9 covered notifications.

That closed on the other side of the bet: what it takes to ship a component that isn’t one of Lattice’s own. Every custom renderer so far has lived inside the app using it. Distribute one — a signature pad, a chart widget, a domain-specific field — and the usual answer is a second release pipeline: an npm package for the React side, kept in version lockstep with the PHP side, hoping nobody installs one without the other. Lattice collapses that into a plain composer require.

One command

--package on the component generator scaffolds the whole thing on first use, deriving the name and PSR-4 namespace from the folder:

php artisan lattice:component Signature --package=packages/acme-signature

packages/acme-signature becomes acme/signature, namespaced Acme\Signature\. Point it at a directory with no composer.json yet and Lattice writes one, then creates the component inside it.

What’s in the box

Four pieces. The composer.json declares two extra.lattice keys: plugin, the TS entry point, and discover, the PHP roots to scan. Then the PHP class, carrying its wire type like any component:

use Lattice\Lattice\Attributes\AsComponent;
use Lattice\Lattice\Core\Components\Component;

#[AsComponent('signature')]
final class Signature extends Component
{
    public string $label = 'Sign here';
}

A RendererComponent<"signature"> TSX file renders it, and a plugin entry ties the wire type to the renderer:

import { createPlugin, lazyComponent } from '@lattice-php/lattice';

export default createPlugin({
    name: 'acme-signature',
    components: {
        signature: lazyComponent(() => import('./signature')),
    },
});

createPlugin and lazyComponent are the same registry primitives an app’s own frontend entry uses for its own components — a package is just another plugin, sourced from vendor/ instead of resources/.

The trick: React that ships inside Composer

plugin is the design point. Lattice’s lattice() Vite plugin scans vendor/composer/installed.json on boot; for every package declaring extra.lattice.plugin, it grants Vite filesystem access to that package’s directory and exposes the plugin under a virtual module, virtual:lattice/plugins. The package’s TSX doesn’t get pre-built and published anywhere — it compiles straight into the consumer’s own bundle. One shared React instance, full tree-shaking, @lattice-php/lattice resolved against the copy already sitting in the consumer’s node_modules. No registry involved at any point.

discover does the equivalent job on the PHP side: it merges the package’s source roots into lattice.discover, so #[AsComponent] classes are found alongside the app’s own, and php artisan lattice:typescript types node.props for the package’s components exactly like it would for a local one.

Installing one

For the consumer, it really is just:

composer require acme/signature

The standard bootstrap already passes virtual:lattice/plugins to createLatticeApp, so an installed package self-registers — no import to add, no plugin array to edit. Run lattice:typescript afterward to pick up the new node.props types. The one hard requirement: the consuming app has to use the lattice() Vite plugin, since that’s what turns installed.json into filesystem access and a virtual module — starter-kit apps have it by default, a bare Vite config wouldn’t. Style package components with the pre-compiled lt- tokens — bg-lt-surface, text-lt-fg, border-lt-border, rounded-lt-sm — and a consumer needs zero Tailwind configuration to theme it correctly; arbitrary utility classes would mean every consumer editing their content sources for a vendor path. More in the component packages docs.

From using to extending

That’s the last of the core tour, and a different kind of entry than the previous nine: not a feature to reach for, but a seam to build on. The series has spent nine parts on what it’s like to use Lattice — pages, forms, tables, actions, layouts, fragments, realtime, notifications — and this one on what it takes to extend it. There’s more on that side I haven’t written about yet, including an experimental take on pulling a component’s data from a different application entirely, that I’m still not sure has the right shape. That’s for whenever it’s ready to talk about.