orchid/crud
Orchid CRUD is a screen-based toolkit for building admin panels and CRUD interfaces in Laravel. Define screens, fields, and layouts in PHP to create customizable back-office apps with forms, filters, sorting, and actions, all within your Laravel codebase.
orchid/crud is not a standalone package — it is the screen-generation layer that sits on top of orchid/platform. Every "setup" story starts with a working Orchid admin panel.
Prerequisites (do these once, outside this package):
composer require orchid/platform
php artisan orchid:install # publishes config, migrations, admin guard
php artisan migrate
php artisan orchid:admin admin [email protected] password
Confirm you can log into /admin before touching CRUD. If that fails, CRUD will only add noise to the debugging.
Minimal path to your first screen:
composer require orchid/crud
php artisan orchid:resource Tag # generates app/Orchid/Resources/TagResource.php
Then fill in the three methods that actually matter — $model, fields(), columns():
namespace App\Orchid\Resources;
use App\Models\Tag;
use Orchid\Crud\Resource;
use Orchid\Screen\Fields\Input;
use Orchid\Screen\TD;
class TagResource extends Resource
{
public static $model = Tag::class;
public function fields(): array
{
return [
Input::make('name')->title('Name')->required(),
Input::make('slug')->title('Slug')->required(),
];
}
public function columns(): array
{
return [
TD::make('name')->sort()->filter(),
TD::make('slug'),
TD::make('created_at')->sort()
->render(fn (Tag $tag) => $tag->created_at?->toDateString()),
];
}
public function rules(\Illuminate\Database\Eloquent\Model $model): array
{
return [
'name' => 'required|max:255',
'slug' => ['required', \Illuminate\Validation\Rule::unique('tags', 'slug')->ignore($model)],
];
}
}
Register the resourceful routes once, in routes/platform.php:
Route::resource('tags', \App\Orchid\Resources\TagResource::class);
Reload /admin and you have list, create, show, edit, and delete screens, plus a navigation entry and a permission-aware menu item. No controller, no Blade file, no route-per-action.
Where to look first, in this order:
Orchid\Crud\Resource in vendor/orchid/crud/src/Resource.php to see every overridable method (displayInNavigation, icon, group, sort, perPage, with, filters, actions, buttons, permission, screens, request, …). That file is your real documentation.vendor/orchid/crud/src/Screens/ — the default List/Create/Edit/View screens. Read them before you try to customise behaviour; most "how do I change X" questions are answered by seeing which method the screen calls.Field and TD — CRUD does not re-document the field library, and the field library is where 80% of your day-to-day work lives.Best first use case: an internal entity nobody wants to design — tags, categories, feature flags, webhook endpoints, support ticket statuses. Twenty lines of resource replaces a controller, two Blade views, a form request, and a nav entry. That is the value proposition, and it is worth proving on something disposable before you commit a roadmap to it.
The pattern that makes CRUD cheap is refusing to spread an entity across files. Fields, table columns, validation, filters, actions, navigation placement, and permissions all live in the resource. When a PM asks "can we add a status column and filter by it?", the answer is one diff in one file.
public function filters(): array
{
return [
Filter\Where::make('status'),
Filter\Where::make('owner_id')->fromModel(Owner::class, 'name'),
];
}
public function with(): array
{
return ['owner']; // eager-load anything a column renders
}
public function perPage(): int
{
return 50;
}
public function group(): ?string
{
return 'Content';
}
exists, push complexity into a request classrules() receives the model, which is exactly what you need for unique rules on create vs update:
public function rules(Model $model): array
{
return [
'slug' => [
'required',
Rule::unique('posts', 'slug')->ignore($model),
],
'published_at' => 'nullable|date|after:now',
];
}
When rules grow past a screenful — or you need custom messages/attribute names/authorization — move them to a resource request class and point at it:
public function request(): string
{
return \App\Orchid\Requests\PostRequest::class;
}
Keep rules() trivial and delegate. Mixed sources of validation truth are how "why did this save with a blank slug" bugs are born.
Saves does the restOrchid's Saves trait handles relation persistence when the field name carries the relation prefix. The generated CRUD screens already use it, so you get pivot sync for free as long as you name fields correctly:
Relation::make('tags.')
->title('Tags')
->fromManyToMany(Tag::class, 'name')
->applyScope('published'),
Simple foreign keys stay as plain selects; only many-to-many or has-many editing needs the trailing dot.
The default action set (view/edit/delete) comes from actions(). Override it to trim or extend, and use the 4.4.0 hide method for role/state-based visibility instead of hand-rolling conditional Blade:
public function actions(): array
{
return [
DeleteAction::make()
->hide(fn (Post $post) => $post->is_locked),
ExportAction::make()
->hide(fn () => ! request()->user()?->hasAccess('platform.posts.export')),
];
}
For genuinely custom workflows, extend the package's Action base class, implement the handler, and declare the button/modal it renders. Keep the handler guarded — see the gotcha below.
permission() on the resource gates the whole screen set and is the right place for "only support leads can touch billing". Row-level and action-level gating belongs in hide() plus a real check in the handler. Two layers, deliberately:
public function permission(): ?string
{
return 'platform.billing.manage';
}
PlatformProviderdisplayInNavigation(), group(), icon(), sort(), and title()/description() fully describe the menu entry. This is the single biggest time saver versus vanilla Orchid, where every screen is registered by hand in app/Orchid/PlatformProvider.php. Keep those methods cheap — see the performance gotcha.
When the default List screen isn't enough (bulk operations, exports, a custom layout), don't edit vendor files and don't abandon the resource. Subclass Orchid\Crud\Screens\ListScreen (or the relevant screen), add your command bar / buttons / layout, and map the default to yours via the resource's screens() method. You keep fields, columns, filters, and validation in one place and only diverge where you must.
app/Orchid/Resources/ and keep models in app/Models/ — the resource is presentation/validation, the model owns business logic. Fire model events (saved, deleted) for side effects rather than stuffing them into resource hooks.get the index route as an authorized user, post the store route with valid and invalid payloads, and assert 403 for an unauthorized role. Three tests per resource catches most regressions introduced by an Orchid bump.hide() is not authorization. It removes a button from the DOM. It does not protect the endpoint. Anyone with the route can still POST the action. Every hide() closure needs a matching server-side check in the handler, the resource's permission(), or a policy. Treat hide() as information-disclosure hygiene and record it in access reviews as exactly that.
orchid/crud tracks orchid/platform's release cadence — version-lock them together. An Orchid major can change CRUD behaviour with no CRUD version bump. Pin both, upgrade both in the same PR, and put a standing yearly slot on the calendar for Laravel's major cycle. Laravel 12 support landed in 4.4.0, but CRUD cannot outrun its host: verify orchid/platform itself supports Laravel 12 before you commit to a framework upgrade date.
displayInNavigation() runs on every menu render. It has no cache of its own. A DB query or expensive can() check there is a per-request tax on every admin page, not just the resource's. Memoise or keep it to a hasAccess() call. Additionally: 4.4.0 fixed request()->user() being null in this method, which changes behaviour for guest-reachable, tenant-scoped, and impersonated admin routes. Write it null-safe (request()->user()?->...) and re-test those three flows after upgrading — they are the ones the fix touches.
Model $fillable/$guarded still governs persistence. The classic "my field renders but nothing saves" bug is a column missing from $fillable, or a field missing from fields() entirely (fields not declared are never saved). Check both before suspecting the package.
Field names and rule keys must agree. Keep them unprefixed and identical to the column. If you adopt a prefix convention (post.title), the keys in rules(), messages(), and attributes() must use the same prefix or validation silently passes.
TD::make(...)->sort() sorts by the column name, not by what ->render() prints. Sorting a relation-derived column (author.name) will not work without a join or a scope. Either render the raw foreign key with a filter, or accept an unsortable column.
Filters need a model that participates. ->filter() on a TD and entries in filters() require the model to use Orchid's filterable trait; without it you get an empty dropdown or a filter that does nothing. The failure mode is silent, so verify the filter actually narrows the query on first build.
->render() closures run once per row. Fine for ->toDateString() or ->statusLabel(). Not fine for lazy-loading a relation. Pair every relation-backed column with with() and watch the query count in Debugbar on a 50-row page before shipping.
Deleting is a hard delete by default. If the model uses SoftDeletes, the default action respects the model's delete(), but the UI will present it as permanent. Consider a restorable "Archive" action and a hide() on delete for roles that shouldn't hard-delete.
Debugging a 404 or a missing menu item:
php artisan route:list --path=platform — is the route registered? If not, routes/platform.php isn't loading or the Route::resource line is missing.displayInNavigation(), permission(), and any cached routes/config (php artisan optimize:clear).columns() returns TD instances (not fields) and fields() returns Field instances (not TD).$fillable, then rules() keys, then whether the field name matches the DB column.Route naming collisions. Two resources on the same URI segment produce colliding route names and unpredictable action routing. Namespace related URIs (content.tags, content.posts) rather than registering two tags resources from different modules.
Don't edit vendor screens. Every customisation should be a subclass or a resource method. Patch files in vendor/ are the reason teams get stuck a Laravel major behind — and with a small contributor base and few downstream dependents, you are your own support contract. Budget a short internal README covering your resource conventions, your permission-key format, and your screen-override policy; that document is the actual mitigation for the expertise concentration risk.
Useful extension points, roughly in order of how often you'll reach for them: columns() render closures → with() for eager loading → filters() → actions() with hide() → a custom request class → screens() for a subclassed screen → a fully bespoke Orchid screen registered alongside, for the one page the resource abstraction genuinely doesn't fit.
Know when to step out. If a screen needs a bespoke layout, multi-step wizard, or real-time updates, build a normal Orchid screen next to the resources rather than contorting the resource API. The package's value is that conventional CRUD is nearly free; it is not a general-purpose UI framework, and fighting it costs more than the boilerplate it saved.
How can I help you explore Laravel packages today?