Component packages: ship a Lattice component, not a release pipeline
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.