moonshine/contracts
Core contracts and interfaces for MoonShine, the Laravel admin panel. This package defines shared abstractions used across MoonShine components, helping developers build consistent, extensible admin panels and integrate custom resources, fields, and actions.
MoonShine is a Laravel admin panel builder: you describe your Eloquent models as Resources and get CRUD screens, tables, filters, and forms without writing controllers or Blade views.
composer require moonshine/moonshine
php artisan moonshine:install # publishes config, migrations, assets, provider
php artisan migrate
php artisan moonshine:user # create the first admin
The panel lands at /admin. Look at these in order:
config/moonshine.php — auth guard/provider, route prefix, middleware, disk for uploads, cache, title/logo.app/MoonShine/Resources/ — the generated MoonShineUserResource / MoonShineUserRoleResource are working reference implementations; read them before writing your own.app/MooonShine/Providers/MoonShineServiceProvider.php — menu definition, assets, and (depending on version) resource registration.app/MoonShine/Layouts/ — the top-level Blade shell if you need to change chrome rather than content.Best first use case: take one existing model, generate a resource, and get index + form + validation working end to end before touching menus, permissions, or theming.
php artisan moonshine:resource ArticleResource --model=Article
That single command is the fastest way to learn the fluent field API, because the generated stub already wires fields(), rules(), and table columns.
Separate screens when they diverge. fields() is a shortcut used by all screens; as soon as detail and index need different columns, split into formFields() / indexFields() / detailFields(). Same for rules() vs per-screen rule sets.
Fields are the whole UI layer. Layout is composable — Fieldset, Tabs, Block — and fields are fluent:
public function formFields(): array
{
return [
Block::make('Content', [
Text::make('Title', 'title')->required()->hint('Shown in listings'),
Slug::make('Slug', 'slug')->from('title')->unique(),
Select::make('Status', 'status')->options(Status::options())->default('draft'),
TinyMCE::make('Body', 'body'),
Image::make('Cover', 'cover')->disk('public')->dir('articles'),
Json::make('Meta', 'meta')->keyValue(),
]),
Fieldset::make('Publishing', [
Date::make('Published at', 'published_at')->format('Y-m-d H:i'),
]),
];
}
Relationships. Use HasMany, BelongsTo, BelongsToMany, and RelationRepeater fields rather than hand-rolled selects; they handle the pivot writes and the async search for you. Add ->searchable() and ->asyncSearch() on large related tables, otherwise the select loads every row on every form render.
Filters and query shaping. Keep filtering inside the resource's filters() with closures over the query builder, and put eager loads in the model's $with or a resource-level query hook. Anything that touches getQuery() runs on every index request — treat it as hot path.
Actions instead of custom routes. Action / BulkAction / FormAction classes give you buttons on rows, headers, or mass selection, with confirmation modals and a handle() method. Reach for these before adding bespoke controllers; you keep the panel's authorization and notification plumbing for free.
Custom pages (moonshine:page) are controllers-less classes returning a view or fields — good for dashboards and settings screens. When you need real logic, a Page plus a small Action is usually cleaner than a custom route outside the panel.
Menu and access. Define the menu centrally in the service provider and gate items with ->canSee(fn () => auth()->user()->isAdmin()). For per-model access, register ordinary Laravel policies against your models — MoonShine respects viewAny / create / update / delete.
Extension points worth knowing: moonshine:field to generate a custom field class, moonshine:component for a Blade + Alpine component usable inside fields, moonshine:theme for a custom CSS theme, and MoonShineServiceProvider::boot() (MoonShine::assets([...])) to register JS/CSS. Publish any vendor view with php artisan vendor:publish --tag=moonshine-views before overriding it.
Field names must resolve on the model. A typo in the second argument of Text::make('Title', 'titel') doesn't fail at boot — it fails when the row renders, or worse, silently drops the value on save. If a field isn't persisting, check the name against $fillable and any accessor/mutator first.
fields() is a trap for new projects. It feels concise, but every screen inherits every field, including heavy ones like Json and TinyMCE. You'll pay for it in index query cost. Split early.
Uploads need two things: a storage:link and the right disk in config/moonshine.php. A missing symlink shows broken images with no error in the log — check the network tab before debugging code.
Cache goes stale on deploy. MoonShine caches menu, counts, and some resource metadata. Add a cache-clear step to your deploy script so a new menu item or permission doesn't mysteriously fail to appear in production but work fine locally.
Version alignment across the monorepo. moonshine/moonshine pulls in moonshine/contracts and moonshine/ui; these are released in lockstep. Only constrain moonshine/moonshine in composer.json and let Composer resolve the rest — hand-pinning contracts is the usual cause of Call to undefined method after an upgrade. The 4.18.1 tag on moonshine/contracts is a patch-level bump with no breaking changes, so staying on a ^4.18 constraint is safe.
Run migrations on every minor bump. 4.x minors routinely ship new tables/columns and config keys. After composer update, run php artisan migrate and diff config/moonshine.php against the newly published stub — an added key with a default you don't have can change behaviour quietly.
Route caching and closures. If you route:cache in production, avoid closures in places MoonShine serialises. Menu items and actions defined inline are fine, but custom route registrations inside the panel's service provider are not.
Debugging a blank or 403 panel: in order — auth guard in config/moonshine.php pointing at the right provider, user having the panel's role, policy method returning false, then a stale cache. A 403 with a working login is almost always policy or role, not configuration.
Performance on large tables: mark only indexed columns sortable(), keep Column::make(...)->sortable() off for computed attributes, and prefer ->asyncSearch() on relationship selects. The default index loads a page of rows with all index fields; dropping a Json or relation column from indexFields() is often the single biggest win.
Don't fight the layout system. When a screen needs something structurally different, a custom Page with your own Blade view is faster to maintain than stacking when() conditions onto fields.
How can I help you explore Laravel packages today?