Component packages
The extension points assume a component lives in your own app. To distribute one — a signature pad, a chart widget, a domain-specific field — you would normally also publish an npm package for its React renderer, and keep it in lockstep with the PHP side. Lattice removes that second release pipeline: a component package can ship its React source for Vite consumers and a precompiled module for no-build apps inside the same Composer release. A plain composer require is enough.
What a package ships
Section titled “What a package ships”Four source pieces — or let the generator write them. Point --package at a directory that has no composer.json yet and Lattice scaffolds the package on first use (the name and PSR-4 namespace are derived from the folder — packages/acme-signature → acme/signature, Acme\Signature\), then creates the component inside it:
php artisan lattice:component Signature --package=packages/acme-signature1. A composer.json declaring the Lattice entry points. The generator writes plugin and
discover; add standalone after configuring the separate precompile described below:
{ "name": "acme/signature", "autoload": { "psr-4": { "Acme\\Signature\\": "src/" } }, "extra": { "lattice": { "plugin": "resources/js/plugin.ts", "css": "resources/css/signature.css", "icons": "resources/icons", "standalone": "dist/plugin.js", "discover": ["src"] } }}2. The PHP component, carrying its wire type:
use Lattice\Core\Attributes\AsComponent;use Lattice\Ui\Components\Component;
#[AsComponent('signature')]final class Signature extends Component{ public string $label = 'Sign here';
public static function make(?string $key = null): static { return new self($key); }
public function label(string $label): static { $this->label = $label;
return $this; }}3. The React renderer for that type:
import type { RendererComponent } from "@lattice-php/core/types";
const Signature: RendererComponent<"signature"> = ({ node }) => ( <div className="rounded-lt-sm border border-lt-border p-4 text-lt-fg"> {String(node.props?.label ?? "Sign here")} </div>);
export default Signature;4. The plugin entry that registers it:
import { lazyComponent, type Plugin } from "@lattice-php/lattice/runtime";
export default { name: "acme-signature", components: { signature: lazyComponent(() => import("./signature")), },} satisfies Plugin;The optional dist/plugin.js is a single precompiled ESM file. Build it with react, react-dom,
react/jsx-runtime, and @lattice-php/lattice/runtime left as external imports; the standalone host
maps those specifiers to its own React and Lattice instances. Bundle every other dependency and
inline dynamic imports so the Composer package does not need to publish additional chunks, and
define process.env.NODE_ENV away — the file runs in the browser as-is.
How it reaches the app
Section titled “How it reaches the app”Each extra.lattice key is read by one side of the stack:
plugin— thelattice()Vite plugin scansvendor/composer/installed.json, and for every package that declares it, grants Vite filesystem access to the package directory and exposes its plugin under the virtual modulevirtual:lattice/plugins. The package’s TSX compiles straight into the consumer’s bundle: no separate build step, one shared React instance, full tree-shaking.css— the plugin aliases it as@<vendor>/<name>/css(lattice-php/signature-example→@lattice-php/signature-example/css); the consumer imports that specifier from their Tailwind entry. See Styling.icons— a directory of.svgfiles the plugin merges into the app’s icon sprite. See Icons.standalone—php artisan lattice:assetscopies the precompiled module intopublic/vendor/lattice/pluginsand adds its versioned URL to the standalone boot config.discover— Lattice’s PHP discovery merges these roots intolattice.discover, so the package’s#[AsComponent]classes are picked up byphp artisan lattice:typescript(which typesnode.propsfor the package’s components) and by definition discovery for any forms, tables, or pages the package also ships.
A package that owns full screens (an auth UI, say) does not need to register a route for each one — its controllers can return a Page directly instead; see Embedded pages.
Installing one
Section titled “Installing one”For the consumer, it is a single dependency:
composer require acme/lattice-signatureIf you followed the installation guide, the discovered plugins are already registered — the standard bootstrap passes virtual:lattice/plugins to createLatticeApp, so an installed package registers itself with no further wiring:
import plugins from "virtual:lattice/plugins";
createLatticeApp({ plugins });(Or merge them onto the registry yourself with extendRegistry(registry, ...plugins).) Then use the component like any built-in:
Signature::make('signature')->label('Sign the contract');Verifying your package is discovered
Section titled “Verifying your package is discovered”php artisan about has a Lattice section listing the configured discover paths, the discovery
roots and JS plugin each installed component package contributes, and whether the discovery
manifest is cached. Run it after composer require-ing a package to confirm it was picked up
without inspecting installed.json by hand.
Testing your package
Section titled “Testing your package”A package’s own test suite (a Testbench workbench, typically) never appears in
vendor/composer/installed.json — Composer treats it as the ROOT project while its tests run, not
an installed dependency. Both discovery sides account for this: PHP discovery also reads the
package’s own composer.json for extra.lattice.discover, and the Vite plugin also reads it for
extra.lattice.plugin. The same composer.json from What a package ships
is enough — no pushing config into the workbench app, and no difference between how the package
discovers itself and how a consumer discovers it once installed. This isn’t specific to a
testbench workbench, either — any composer ROOT (including a real app) can declare
extra.lattice.discover/plugin directly in its own composer.json, and it is picked up the same
way.
Styling
Section titled “Styling”Use Lattice’s lt- design tokens — bg-lt-surface, text-lt-fg, border-lt-border,
rounded-lt-sm — in a package component. They ship pre-compiled in @lattice-php/lattice/css, so
the component is themed correctly with no extra Tailwind configuration in the consuming app.
A package that needs more than tokens ships its own stylesheet. Create
resources/css/<name>.css starting with an @source directive pointing at the package’s JS:
@source "../js";
.acme-signature-pad { border-color: var(--lt-border);}Declare it as extra.lattice.css:
"extra": { "lattice": { "css": "resources/css/signature.css" }}The consumer adds an @import for the aliased specifier to their Tailwind entry, after the Lattice
css import:
@import "@lattice-php/lattice/css";@import "@acme/signature/css";The @source line makes the consumer’s Tailwind build scan the package’s components, so arbitrary
utility classes in the package’s TSX work like any first-party component — no vendor path needs
adding to the consumer’s content sources. Bespoke rules built on --lt-* tokens can follow the
@source line, as above. See packages/signature-example/resources/css/signature-example.css for
the reference implementation.
extra.lattice.icons names a directory of .svg files that the lattice() Vite plugin merges into
the app’s icon sprite:
"extra": { "lattice": { "icons": "resources/icons" }}The sprite is a single flat namespace, merged in order — the built-in ui icons first, then every
discovered package (in composer order), then the app’s own icon dirs — and later entries win on a
name collision. Prefix icon names with the package name (signature-example-pen) to avoid
colliding with another package; an app can still deliberately override a package icon by shipping
one of the same name in its own icon dir.
Render an icon like any built-in one:
import { Icon } from "@lattice-php/ui/icons";
<Icon name="signature-example-pen" />;The generated KnownIcons TypeScript augmentation includes package icons automatically, so
name stays typed in the consuming app.
Translations
Section titled “Translations”A package component translates like a built-in one: declare an i18next namespace on the plugin and read keys with useT, passing the English default inline at the call site:
import { lazyComponent, type Plugin } from "@lattice-php/lattice/runtime";
export default { name: "acme-signature", components: { signature: lazyComponent(() => import("./signature")), }, i18n: { namespace: "acme-signature", },} satisfies Plugin;import { useT } from "@lattice-php/lattice/runtime";
const Signature: RendererComponent<"signature"> = ({ node }) => { const { t } = useT("acme-signature");
return ( <div> {typeof node.props?.label === "string" ? node.props.label : t("placeholder", "Sign here")} </div> );};createLatticeApp merges every plugin’s namespace into the i18n bootstrap, so when the app enables the translation backend the namespace loads from /locales/{lng}/acme-signature.json like any other — serve the package’s lang files by registering them in the package’s service provider:
use Lattice\Core\Facades\Lattice;
Lattice::translations('acme-signature', __DIR__.'/../lang');Drag and drop
Section titled “Drag and drop”A package that needs drag-and-drop — reordering a list, moving cards between columns — imports the
primitives from @lattice-php/lattice/dnd (Atlassian’s pragmatic-drag-and-drop, re-exported by
core) instead of depending on the library directly:
import { draggable, attachTreeItemInstruction, announce, type Edge,} from "@lattice-php/lattice/dnd";A Composer package has no way to deliver npm dependencies into the consumer’s bundle, so core owns
the dependency and republishes it under its export map, the same way core owns i18next behind
.../i18n above.
Testing the package
Section titled “Testing the package”Extend Lattice\Support\Testing\PackageTestCase instead of hand-writing a Testbench
TestCase: it boots Inertia and Lattice around the package’s own providers on an in-memory sqlite
app, applies the package’s config overrides before boot, pulls in the
Lattice component assertions, and wires the conventional workbench/ view and
migration paths when they exist:
use Lattice\Support\Testing\PackageTestCase;
abstract class TestCase extends PackageTestCase{ protected function packageProviders(): array { return [AcmeSignatureServiceProvider::class]; }
protected function packageConfig(): array { return ['lattice.discover' => [__DIR__.'/../workbench/app']]; }}For Pest browser suites, extend PackageBrowserTestCase instead — it additionally fails fast with
an actionable message when the workbench Vite build is missing or a stale dev-server marker is left
behind, and widens Playwright’s timeout for CI runners.
See Registry and types for how the type string couples the two sides, and how generated types keep node.props sound.
A package that adds a rich-editor node follows the same PHP-class-plus-client-definition shape, plus server-side seams of its own for rendering, sanitizing, and validating that node — see Server-side extensions.