tightenco/laravel-elm
Laravel Elm brings Elm components into your Laravel app, letting you write type-safe, interactive front-end code with a Livewire-like developer experience. It handles compiling Elm assets and wiring them into your Blade views.
Install, publish, and verify the actual surface before writing any Elm:
composer require tightenco/laravel-elm --dev
php artisan vendor:dublish --provider="Tighten\Elm\ElmServiceProvider" # see below
php artisan list | grep -i elm # confirm command names for your pinned version
npm install --save-dev elm elm-webpack-loader
(The publish command name varies by pin — check php artisan vendor:publish interactively. Don't copy a command out of a README without running php artisan list first; on a package this small the command set is the source of truth.)
Where to look first, in order:
config/elm.php — source paths, output directory, package settings.resources/assets/elm/ after scaffolding, especially elm.json and the generated module's imports.loganhenson/elm-laravel — generated modules import it, so its API is your Elm-side contract.First use case: one widget, one flag. Pick something trivial and self-contained — a counter, a filter control, a status badge with refresh. Scaffold it, mount it into one Blade view, pass one array as flags, and get Mix producing a fingerprinted asset. That exercises the whole seam end to end: generator → Elm module → decoder → Blade mount → flags JSON → bundle → browser.
php artisan elm:make Counter
// webpack.mix.js — the package injects an elm() rule; verify it survived
mix.elm('resources/assets/elm/Counter.elm', 'public/js');
mix.version();
<div id="counter-root"></div>
@elm('Counter', ['startingCount' => $post->views])
The payoff you're buying: the controller hands Elm a typed payload at boot, so the widget is interactive on first paint — no fetch waterfall, no loading spinner, no null guards. Json.Decode either produced your record or the program failed loudly.
Treat flags as an API contract. Flags are the only channel for Laravel data into Elm, and decoders are unforgiving: a missing key or a stringified number is a hard boot failure. Shape the payload explicitly in a presenter rather than dumping a model:
public function edit(Post $post)
{
return view('posts.edit', [
'flags' => [
'post' => PostFlags::make($post),
'canEdit' => Gate::allows('update', $post),
],
]);
}
A small presenter class per payload is the cheapest insurance available. If you adopt the model→decoder generator, the presenter and the generated type alias are the two halves of one contract — change them in the same PR.
Generator loop, with a committed diff. Where codegen exists, the day-to-day workflow is: ship migration + model change → run the generator → commit the generated Elm module alongside the PHP → review the generated diff in the PR like any other contract change → extend behavior in a sibling module, never hand-edit generated output. The review step is the value; unreviewed generated code is just drift you didn't type.
One Elm program per interactive island. Don't let a single Program own the whole page. Blade owns the shell; each island gets its own mount node, its own flags, its own Elm.X.init call:
document.querySelectorAll('[data-elm]').forEach((node) => {
const app = Elm[node.dataset.elm].init({
node,
flags: JSON.parse(node.querySelector('script[type="application/json"]').textContent),
});
wirePorts(app);
});
Ports, not globals, for the return path. Elm is closed; ports are the only sanctioned escape hatch. Wire them in the mount script, not scattered through Blade:
app.ports.savePost.subscribe(async (payload) => {
await fetch(`/posts/${payload.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify(payload),
});
});
Put the CSRF token in a layout <meta> once — it's environment, not data; flags are data.
Build pipeline conventions. Include the Elm compile in npm run production, keep mix.version() on and reference assets with mix() in Blade. Never both commit public/js and deploy it — pick one. In CI, cache ~/.elm and elm-stuff/, and run a cold-cache elm make as its own job so Elm breakage fails fast and separately from the rest of the asset pipeline. Locally, npm run watch plus elm-format --validate in a pre-commit hook keeps the loop tight.
Two cheap tests that catch the drift class. elm-test the decoders by feeding them the exact JSON your presenter produces — that's where the contract is pinned. Then a Laravel feature test asserting the flags shape (assertViewHas, or decode the rendered JSON). These catch the failure that otherwise only manifests as a blank <div> in staging.
Version compatibility is the top risk, not a footnote. This is a Mix-era package (elm-webpack-loader rules, webpack.mix.js patching) at 26 stars with zero dependents. Verify Composer constraints against your Laravel/PHP majors and that the Mix extension still loads on your laravel-mix. If you're on Vite, assume the asset step does not work — but the extension is a handful of lines around elm make, so it's a small, scoped fork, not a rewrite.
Pin and plan to own it. Read the source, keep a fork or path repository ready, and keep the Laravel↔Elm contract (JSON shapes, CSRF, fingerprinting) under your own tests so replacing the glue is a swap, not a migration.
Decode failures are silent in the browser. A flags mismatch means the program never mounts and the user sees an empty div. Render fallback content inside the mount node, log the flags object in non-production, and subscribe to whatever failure signal the runtime library offers. "Why is my widget blank" is almost always the decoder.
The generator and your API Resources will drift. Codegen tracks the Eloquent model; responses go through $casts, $hidden, $appends, and Resources. Dates, decimals, and booleans are the usual offenders — "2023-01-01T00:00:00.000000Z" where Elm expects an Int is the classic first failure. Either mirror casts in the generator input, or hand-write per-endpoint decoders and treat the generator as a starting point.
Two bundles both define Elm on window and clobber each other. Use one entry point that imports everything, or namespace deliberately. This is an Elm/bundler sharp edge, not package-specific, but it bites the moment you add a second island.
Unconnected ports fail silently. If app.ports.xyz is undefined, either your mount script ran before the bundle, or the port name doesn't match the .elm file. Check both before suspecting Elm.
Config cache is the usual "wrong folder" cause. Run php artisan config:clear after touching config/elm.php in local dev, and confirm from source whether the path is resolved at boot or at command runtime.
Extension points worth forking: the Blade mount helper (so you control flag serialization, escaping, and mount markup) and the generator's output template (so generated modules match house style and import the runtime library the way your elm.json expects). Both are small; both are where you'll actually want changes.
Scope discipline. Don't embed Elm islands inside an otherwise Blade/Livewire page and call it architecture. It's defensible as Laravel owns the page, Elm owns a bounded surface or Laravel as API, Elm as client — mixing all three frontend paradigms on one surface adds a compiler and a build step without removing any Laravel-side complexity, and you'll be the only person able to debug it.
How can I help you explore Laravel packages today?