area17/twill
Twill is an open source Laravel package for building custom, beautiful CMS admin consoles fast. It standardizes common publishing features with pre-built Vue.js UI components, no lock-in, and full flexibility.
Twill is a Laravel package, not a separate app — you install it into a Laravel project and it layers an admin console on top of your own models.
Install and scaffold:
composer require area17/twill
php artisan twill:install # config/twill.php, config/twill-navigation.php, routes/twill.php, migrations, assets
php artisan migrate
php artisan storage:link # required for media/file library URLs
php artisan twill:superadmin # creates the first admin in twill_users
Confirm the PHP/Laravel constraints before you start (Twill 3 targets Laravel 9/10 era, PHP 8+); pin a 3.x minor in composer.json since releases land frequently.
Where to look first:
config/twill.php — features on/off (enabled), locales, media/file config, block editor, auth guard.config/twill-navigation.php — the admin sidebar; this is the map of your CMS.routes/twill.php — module routes auto-registered under the Twill middleware group.app/Models, app/Repositories, app/Http/Controllers/Admin, resources/views/admin.twillcms.com/docs, specifically CRUD modules, Form fields, Block editor, Relations.First use case — one module end to end: generate an Article module, migrate, add a couple of form fields, register it in navigation, and create a record. Then do the same on an existing model (Twill explicitly supports hooking existing tables via traits) — that second pass is the real "does this fit?" test.
The generated module triad. Twill generates a Model + Repository + Controller + Request + migration + Blade form. Day-to-day work happens almost entirely in:
HasMedias, HasFiles, HasBlocks, HasSlug, HasRevisions, HasPosition, HasTranslation, HasRelated, HasNestedItems), plus $fillable, $translatedAttributes, $mediasParams.extends A17\Twill\Repositories\ModuleRepository): declare $model, then use beforeSave/afterSave hooks and behavior traits (Sluggable, Mediable, Blockable, Fileable, Revisionable, Sortable, Related, Translation, Nestable) for persistence logic.Keep custom code in repositories and model observers so the admin UI stays declarative.
Form builder, not hand-written HTML. Use Blade components inside the generated form view:
@extends('twill::layouts.form')
@section('contentFields')
<x-twill::input name="title" label="Title" :required="true" />
<x-twill::wysiwyg name="body" label="Body" />
<x-twill::medias name="hero" label="Hero image" />
<x-twill::block-editor />
@endsection
Older @formInput-style directives still render (they’re converted to components internally), but write components for new work — the directives are on a deprecation path. Sidebar fields go in sideFieldsets/fieldsets.
Media with named crops. Define roles and crops once in $mediasParams and treat those crop names as the contract with your front end:
public $mediasParams = [
'hero' => [
'desktop' => [['name' => 'default', 'ratio' => 16/9]],
'mobile' => [['name' => 'mobile', 'ratio' => 1]],
],
];
Then $article->image('hero', 'desktop') (or imageAsArray for srcset) in Blade/API responses. Regenerate crops when you add ratios.
Structured content: blocks vs repeaters. Blocks for editorial flexible content (JSON in content), repeaters for smaller repeated field groups, real related models when you need to query/query-count them. Render with $model->renderBlocks() and one Blade view per block type; renderBlocks(false) returns the structured array if you want to transform it (e.g., for an API) instead of rendering.
Listing screens via the table builder. Override the controller’s index table columns/filters with OOP classes rather than fighting views: Text::make()->field('title')->linkToEdit(), image/relation columns, and filter classes. This keeps sorting, searching, and permission-aware actions consistent.
Workflow features you get for free — wire them deliberately:
published flag + publish date windows; use scopes in your front-end queries.HasRelated for curated, reorderable lists.depth config plus parent_id/_lft/_rgt; navigation config gets nested.Route::singleton(...) — no index, no create.Front-end integration. Twill makes no front-end assumptions: use standard Eloquent in your own controllers, or serialize models into an API. Two things worth doing early: (1) hook the preview URL so editors can see drafts, and (2) invalidate your page/response cache in a model observer on save/publish — Twill won’t do it for you.
Admin users are a separate guard/table (twill_users). Don’t conflate CMS editors with your application’s end users; wire role/permission checks (Twill 3 has first-class permission management) against the twill guard.
twill_, and made datetimes fully timezone-aware (UTC storage, browser-local display). If you’re carrying a 2.x app or copied snippets, read UPGRADE.md rather than guessing. Remove any old timezone workarounds.$fillable entries, mismatched $translatedAttributes, or trait ordering issues produce saves that appear to work but drop fields. When a field “doesn’t save,” diff the form field name, the migration column, and $fillable/translated attributes first.twill_slugs, not your table. Override slug generation explicitly (source field / generateSlug()), and remember slugs are per-locale — changing a title shouldn’t silently rewrite URLs for published content.twill:build flow, requiring Node) — editing published files in public/ is a dead end. Stale references usually mean: rebuild, php artisan view:clear, config:clear, and use the manifest-flush command Twill 3 ships for exactly this.storage:link, APP_URL/glide.base_url correctness, and whether the crop ratio you asked for exists in $mediasParams. Missing crop definitions return nothing rather than erroring loudly.php artisan route:list --path=admin, check the user’s role, and confirm the module is registered in config/twill-navigation.php — an unregistered module can be routable but invisible/permission-denied.config/twill.php enabled toggles (media, files, blocks, etc.) remove UI and migrations you don’t need; trimming early avoids dead tables and confusing fields later.getSubmitOptions() for publish/save button behavior, setBreadcrumbs() for context, controller setup methods for option defaults, and capsules/packages for modules reused across projects.APP_DEBUG=true, watch storage/logs/laravel.log and the browser network tab (admin is Vue-driven; failed form saves often surface as a JSON validation response), and confirm a fresh php artisan twill:module:make module works before blaming your customization. When in doubt, diff your generated module against a clean one — most issues are a missing trait, config key, or migration column.How can I help you explore Laravel packages today?