Skip to content

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.

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-signatureacme/signature, Acme\Signature\), then creates the component inside it:

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

1. 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.

Each extra.lattice key is read by one side of the stack:

  • plugin — the lattice() Vite plugin scans vendor/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 module virtual: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 .svg files the plugin merges into the app’s icon sprite. See Icons.
  • standalonephp artisan lattice:assets copies the precompiled module into public/vendor/lattice/plugins and adds its versioned URL to the standalone boot config.
  • discover — Lattice’s PHP discovery merges these roots into lattice.discover, so the package’s #[AsComponent] classes are picked up by php artisan lattice:typescript (which types node.props for 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.

For the consumer, it is a single dependency:

Terminal window
composer require acme/lattice-signature

If 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');

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.

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.

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.

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');

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.

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.