Lean is an admin panel package focused on building fully customized admin panels for customers.
Coming soon, join our waiting list.
1class ReportResource extends LeanResource 2{ 3 public $model = Report::class; 4 5 public $lang = [ 6 'create.title' => 'Vytvořit hlášení', 7 ]; 8 9 public function fields()10 {11 return [12 Status::make('status')13 ->sortable()14 ->options(Report::STATUSES)15 ->colors([16 'reported' => 'warning',17 'acknowledged' => 'info',18 'resolved' => 'success',19 ])20 ->filterable()21 ->searchable()22 ->placeholder('Select a status'),23 ];24 }25}
Every language string used by the package can be translated for each resource individually.
Lean ships with many fields out of the box, making it easy to implement your existing data model.
Thanks to our bleeding-edge tech stack, you can build a custom-feeling admin panel extremely quickly.
Each model gets a resource to tell Lean what fields, actions, titles, labels, and translation strings to use.
We don't generate any files. We don't pollute your codebase with repetitive boilerplate code. It's just resources + whatever you choose to customize.
Completely custom pages & resource actions for when CRUD isn't enough.
Lean ships with a modern & unopinionated theme. You're of course free to customize the UI to match your brand. But if you're short on time — the default one will work great as well.
Lean is an admin panel for humans. Humans use mobiles, therefore Lean has perfect mobile support.
Rather than showing you fluffy marketing copy, let's look
at actual code that you'll be writing with Lean.
Resources tell Lean how to talk to your database. Each resource has a model, searchable columns, a title column, icon, labels, and more.
1class ReportResource extends LeanResource 2{ 3 public static string $model = Report::class; 4 5 public static array $searchable = [ 6 'id', 7 'description', 8 'status', 9 ];10 11 public static string $title = 'description'; 12 public static string $icon = 'heroicon-o-exclamation';13 14 public static function fields(): array
15 {16 // ...17 } 18 19 public static function actions(): array
20 {21 // ...22 } 23 24 public static function bulkActions(): array
25 {26 // ...27 } 28 29 public static function titleFor(Model $model): string30 {31 return Str::of($model->description)->limit(40, '...'); 32 }33}
Use fields to represent database columns, relationships, files, or any other data.
1return [ 2 ID::make(), 3 Textarea::make('description'), 4 Status::make('status')->options(Report::statuses()), 5 Image::make('image_path'), 6 BelongsTo::make('category'), 7 BelongsTo::make('location'), 8 Text::make('concrete_location'), 9 BelongsTo::make('assignee'),10 BelongsTo::make('reporter'),11 UpdatedAt::make(),12 CreatedAt::make(), 13];
Fields can be heavily customized both in appearance and behavior using a fluent API.
1return [ 2 ID::make(), 3 4 Textarea::make('description') 5 ->sortable() 6 ->expanded() 7 ->resolveValueUsing(function (Field $field, mixed $value) { 8 if ($field->action->index()) { // If we're on index... 9 // Add a link to the field. Shift+click opens the Report detail in a modal. 10 $field->link(fn () => static::route('show', $field->model), modal: ['show', '$model']);11 12 return static::titleFor($field->model);13 }14 15 // Otherwise return the normal value16 return $value;17 })18 ->filterable(), 19 20 Status::make('status') 21 ->sortable()22 ->options(Report::statuses()) 23 ->colors([ // Set the colors for individual values24 'reported' => 'warning', // These are defined in config/lean.php25 'acknowledged' => 'info',26 'resolved' => 'success',27 ])28 ->filterable()29 ->searchable() // Make the select searchable 30 ->placeholder('Select a status'),31 32 Image::make('image_path') 33 ->display('show', 'edit') 34 ->resolveUrlUsing(fn (Image $image, string $path) => asset("storage/$path"))35 ->readonly(),36 37 BelongsTo::make('category') 38 ->filterable()39 ->createButton() // Show a create button next to the select 40 ->sortable(),41 42 BelongsTo::make('location') 43 ->hydrated(function (BelongsTo $field) {
44 if (45 $field->action->read() && // If we're on index or show46 $field->value && // And the field has a value47 $concreteLocation = $field->form?->concrete_location?->value48 ) {49 $field->renderAs([$field->indexLink(), ' / ', $concreteLocation]); 50 }51 }) 52 ->filterable()53 ->sortable(),54 55 Text::make('concrete_location') 56 ->filterable()57 ->display('write')58 ->datalist( 59 // Datalist suggests values but lets the user enter a custom text60 fn () => ConcreteLocation::cursor()->pluck('name')->toArray()61 )62 ->sortable(),63 64 BelongsTo::make('assignee') 65 ->filterable()66 ->sortable(),67 68 BelongsTo::make('reporter') 69 ->filterable()70 ->sortable(),71 72 UpdatedAt::make()->filterable(), 73 CreatedAt::make()->filterable(), 74];
You can customize how each action (index/create/show/edit) behaves.
You can also disable,
replace, or duplicate each action.
In the example below, we're using a custom Create action and three separate Index actions.
1// Unprocessed reports = reports which still need to be acknowledged 2Index::make('unprocessed') 3 ->access(fn (User $user) => $user->isSuperAdmin()) // Only let the superadmin see this 4 ->header(buttons: false) // Hide buttons (e.g. 'Create Report') from the header 5 ->title('Unprocessed Reports') 6 ->search(false) // Remove the search bar 7 ->filters(false) // Remove filters 8 ->advanced(false) // Remove all advanced search features 9 ->fields(except: ['status', 'reported_by']) 10 ->menuLink(fn (MenuLink $link) => $link->icon('heroicon-o-bell'))11 ->buttons([12 $acknowledge, // Sets the status to 'acknowledged' 13 $delegate, // Delegates the task to another administrator14 'delete', // Default delete button to discard spam reports15 ])16 ->scope(fn (Builder $query) => $query->where('status', 'reported')) 17 ->perPage(15)18 ->bulkActions([ // Set the bulk actions that can be run on selected records19 BulkAcknowledge::make(), 20 BulkDelegate::make(),21 ]),22 23// My reports = reports assigned to the current user24Index::make('my') 25 ->fields(except: ['assignee']) // We don't need to show the assignee 26 ->header(buttons: false)27 ->title('My Reports') 28 ->filters(false)29 ->advanced(false)30 ->search(false)31 ->buttons([32 $acknowledge, 33 $resolve,34 'show',35 'edit'36 ])37 ->menuLink(fn (MenuLink $link) => $link->icon('heroicon-o-inbox')) 38 ->scope(fn (Builder $query) => $query39 ->where('assignee_id', auth()->id()) 40 ->where('status', '!=', 'resolved')41 )42 ->bulkActions([43 BulkAcknowledge::make(), 44 BulkResolve::make(),45 ]),46 47Index::make() 48 ->access(fn (User $user) => $user->isSuperAdmin())49 ->fields(except: 'id')50 ->menuLink(fn (MenuLink $link) => $link->label('All Reports'), 51 52Show::make(), // These were not modified 53 54Edit::make(), // These were not modified 55 56// We're using a custom Livewire component for creating reports with better UX -Create::make(), +CreateReport::make('create'),
The example above uses $acknowledge
, $delegate
, and
$resolve
in the buttons()
sections. You may wonder, how are buttons
like that defined? And how do they handle behavior?
Simple, you can just use our Element
classes to create renderable HTML tags with
PHP behavior:
1Button::make('Acknowledge') 2 ->click(function (Report $report) { 3 $report->update([ 4 'status' => 'acknowledged', 5 ]); 6 7 Lean::notify('Report acknowledged!'); 8 }) 9 ->if(fn () => $report->status === 'reported');10 11Button::make('Delegate') 12 ->success()13 ->xClick('$index.bulkAction("bulk-delegate", [$model])'); 14 15Button::make('Resolve') 16 ->success()17 ->click(function (Report $report) {18 $report->update([19 'status' => 'resolved',20 ]);21 22 Lean::notify('Report resolved!'); 23 }) 24 ->if(fn () => $report->status === 'acknowledged');
In the example above, you can see that Acknowledge and Resolve are simple button clicks. Here's what they trigger.
1class BulkDelegate extends BulkAction 2{ 3 public static function name(): string
4 { 5 return 'Delegate'; 6 } 7 8 public static function icon(): string 9 {10 return 'heroicon-o-user-add';11 } 12 13 public function fields(FieldCollection $resource): array 14 {15 return [16 $resource['assignee']->placeholder(text: '--- Select an administrator ---', enabled: true),17 ];18 }19 20 public function above()21 {22 $count = count($this->modelIds);23 24 return AlertPanel::make("$count reports will be delegated.")25 ->icon('heroicon-o-user-add')26 ->class('mt-3')27 ->success();28 } 29 30 public function alpine(): string31 {32 return <<<'JS'33 {34 confirmed() { 35 // This action affects a column by which the user might be filtering results.36 // Therefore, to avoid bugs, we clear the selection after the action runs.37 this.index.selected = []; 38 } 39 }40 JS;41 }42 43 public function buttons(Button $cancel, Button $confirm): array44 {45 return [46 $cancel,47 $confirm->success(), 48 ];49 }50 51 public static function handle(LazyCollection $models, FieldCollection $fields, string $resource)52 {53 $ids = $models->pluck('id')->toArray();54 55 $resource::updateMany($models, [ 56 'assignee_id' => $fields['assignee']->getStoreValue(), 57 'status' => 'acknowledged',58 ]);59 60 return $ids;61 }62 63 public function after($ids) 64 {65 Lean::notify(count($ids) . ' reports delegated!');66 67 return Lean::modal()->confirm($ids);68 } 69}
A few sections above, we instructed Lean to use a custom Livewire component for the
Create
action. Presumably we'll have to make some changes to this component — to
make it work with Lean.
How hard is it? See for yourself.
1class Create extends Component 2{ - use WithFileUploads; + use WithFileUploads, WithResourceAction; 5 6
7 public Collection $locations; 8 public Collection $concreteLocations; 9 public Collection $categories;10 11 // ...12 13 protected $rules = [14 'description' => ['nullable'],15 'location' => ['required', 'exists:locations,id'],16 'category' => ['required', 'exists:report_categories,id'],17 'image' => ['nullable', 'image', 'max:4096'],18 ];19 20 public ?Report $report = null;21 22 public function mount()23 {24 $this->resourceAction(resource: 'reports', alias: 'create'); 25 26 Lean::setTitle($this->resource()::trans('create.title')); 27 28 $this->location = request()->query('location');29 30 // ...31 }32 33 // ...34 35 public function submit()36 {37 $this->validate();38 39 $this->report = Report::create([
40 'description' => $this->description ?: $this->category()->name,41 'location_id' => $this->location()->id,42 'concrete_location' => $this->concreteLocation,43 'category_id' => $this->category()->id,44 'status' => 'reported',45 'reporter_id' => auth()->id(),46 'assignee_id' => $this->category()->assignee?->id,47 'image_path' => $this->image?->store('images'),48 ]); 49 50 if ($this->inLean()) { 51 Lean::notifyOnNextPage($this->resource()::trans('notifications.created')); 52 53 return redirect(54 $this->resource()::route('show', $this->report) 55 );56 } 57 }58}
-<div> +<div @leanAction> 3 + <x-lean::asset name="filepond"> 5 <link href="https://unpkg.com/filepond/dist/filepond.css" rel="stylesheet"> 6 <script src="https://unpkg.com/filepond/dist/filepond.js"></script> + </x-lean::asset> 8 9 <x-slot name="header">10 Create Report11 </x-slot>12 - <div class="shadow-sm sm:rounded-lg"> + <div class="@unless($this->inLean()) shadow-sm sm:rounded-lg border-gray-200 @endunless"> 15 {{-- We can make styles Lean-specific (or only used outside of Lean) by using $this->inLean() --}}16 </div> 17</div>
Not only is Lean fully responsive, it has a dedicated mobile menu and is installable as an app (PWA) on any smartphone or computer.
When you shift+click any link in Lean, it will open in a special modal view. The modal is fully navigable, and when you make any change to a resource, it will be reflected everywhere else on the page.
Lean has extremely advanced (yet easy to use) filters for most fields. The filters are also fully configurable and customizable.
We've taken a completely different approach to localization than other admin panels. We don't have any complex inflection systems or anything like that. We simply let you and your translators specify language strings for each resource individually.
1public static array $lang = [ 2 'create.submit' => 'Vytvořit administrátora', 3 'create.title' => 'Vytvořit administrátora', 4 'edit.submit' => 'Upravit administrátora', 5 'edit.title' => 'Upravit administrátora :title', 6]; 7 8public static function label(): string 9{10 return 'Administrátor';11}12 13public static function pluralLabel(): string14{15 return 'Administrátoři';16}
Build pages from scratch and add them to your admin panel. CRUD actions work well for many things — and save a huge amount of time — but admin panels contain more than that.
1class Statistics extends LeanPage 2{ 3 use WithNotifications; 4 5 public static function menu(MenuLink $link): MenuLink 6 { 7 return $link 8 ->label('Statistics') 9 ->icon('heroicon-o-chart-bar');10 }11 12 public function refresh()13 {14 Cache::forget('stats');15 16 $this->notify('Data was refreshed!');17 }18 19 public function render()20 {21 Lean::fullWidth();22 23 return view('lean.pages.stats', [24 'unresolved' => Report::where('status', 'accepted'),25 'resolved' => Report::where('status', 'resolved'),26 'total' => Report::where('status', '!=', 'reported'),27 ]);28 }29}
You can control the theme, font, and similar things in the comfort of your
config/lean.php
file.
1'colors' => [ 2 /** 3 * The theme color of your admin panel. 4 * 5 * Must be one of the colors in the Tailwind file.
6 * 7 * @example rose pink fuchsia purple violet indigo blue lightBlue cyan teal emerald green 8 * @example lime yellow amber orange red warmGray trueGray gray coolGray blueGray 9 */ - 'theme' => 'blue', + 'theme' => 'purple', 12 13 /**14 * The theme code of your admin panel. 15 *16 * This is used in the manifest and meta tag.17 * Browsers will match the GUI to this color.
18 *19 * @example `default` Use the 700 shade of the color in `theme`.20 * @example `green.500` Use the 500 shade of the `green` color.21 * @example `red` Use the 700 shade of the default `red` color.22 * @example `#7e22ce` Use a custom HEX code for as the color. 23 */ - 'theme_code' => 'default', + 'theme_code' => '#2b9dff', 26 27 'info' => 'blue', 28 'danger' => 'red', 29 'success' => 'green', 30 'warning' => 'yellow', 31],
Don't waste time specifying the same config again and again. You can configure fields upfront and they'll respect those settings — unless you override them.
1Pikaday::make('updated_at')->display('show', 'edit') - ->phpFormat('d.m.Y') - ->jsFormat('DD.MM.YYYY'); 4 5Pikaday::make('created_at')->display('show', 'create') - ->phpFormat('d.m.Y') - ->jsFormat('DD.MM.YYYY'); 8 9Pikaday::make('last_visited_at')->display('show') - ->phpFormat('d.m.Y') - ->jsFormat('DD.MM.YYYY'); 12 13Pikaday::preconfigure(function (Pikaday $pikaday) { 14 $pikaday + ->phpFormat('d.m.Y') + ->jsFormat('DD.MM.YYYY'); 17}); 18 19Pikaday::make('updated_at')->display('show', 'edit'); 20Pikaday::make('created_at')->display('show', 'create'); 21Pikaday::make('last_visited_at')->display('show');
1$ php artisan lean:publish 2What asset type do you want to publish?: 3 [js ] JavaScript files 4 [css ] CSS files 5 [assets ] Frontend assets 6 [config ] Configuration file 7 [views ] Blade views 8 [colors ] Color definitions 9 [translations] Translation files10> css 11Path [All]: 12 [* ] All13 [base.css ] base.css14 [buttons.css] buttons.css15 [inputs.css ] inputs.css16 [main.css ] main.css17> buttons.css 18 19Anything else? (yes/no) [no]: 20> no
5/* ... */ 6 7.btn { 8 /* Base button style */ 9 @apply inline-flex items-center - px-4 py-2 + px-3 py-1.5 12 border border-transparent13 text-base font-almost-bold rounded-md14 focus:outline-none;15}
1$ php artisan lean:build 2ℹ Compiling Mix3Laravel Mix v6.0.254✔ Compiled Successfully in 21085ms
We have an internal Turbolinks fork which provides a flawlessly smooth
experience with perfect cache invalidation and handling of all other edge cases.
Turbolinks is enabled by default, but if you don't want to use it, simply comment out these
two lines of Blade in your Lean layout.
1<html> 2 <head> 3 <x-lean::layout.meta /> 4 <x-lean::layout.styles /> 5 <x-lean::layout.pwa for="head" /> 6 <x-lean::layout.scripts for="head" /> 7 <x-lean::layout.darkmode for="head" /> + <x-lean::layout.turbolinks for="head" /> 9 </head>10 11 <body class="font-sans antialiased">
12 <div class="min-h-screen flex bg-white dark:bg-gray-800 sm:flex-row flex-col">13 ...14 </div> 15 ...16 17 <x-lean::notifications />18 <x-lean::console-log />19 20 <x-lean::layout.scripts for="body" /> 21 <x-lean::layout.pwa for="body" /> + <x-lean::layout.turbolinks for="body" /> 23 </body>24</html>
FAQ
CreateOrder
action), language strings, pieces of the
layout, create custom Pages when CRUD resources aren't enough, and much more.