Lean

Laravel package for building
custom admin panels

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}

Productive & Modern Tech Stack

Localization

Every language string used by the package can be translated for each resource individually.

Fields

Lean ships with many fields out of the box, making it easy to implement your existing data model.

Fast development

Thanks to our bleeding-edge tech stack, you can build a custom-feeling admin panel extremely quickly.

Resources

Each model gets a resource to tell Lean what fields, actions, titles, labels, and translation strings to use.

Clean code

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.

Pages & Actions

Completely custom pages & resource actions for when CRUD isn't enough.

Modern design

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.

Responsive

Lean is an admin panel for humans. Humans use mobiles, therefore Lean has perfect mobile support.

Let's build an admin panel together

Rather than showing you fluffy marketing copy, let's look
at actual code that you'll be writing with Lean.

Start with resources

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): string
30 {
31 return Str::of($model->description)->limit(40, '...');
32 }
33}

Then add fields

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];

Configure fields

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 value
16 return $value;
17 })
18 ->filterable(),
19 
20 Status::make('status')
21 ->sortable()
22 ->options(Report::statuses())
23 ->colors([ // Set the colors for individual values
24 'reported' => 'warning', // These are defined in config/lean.php
25 '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) { ... Click to show ... })
44 if (
45 $field->action->read() && // If we're on index or show
46 $field->value && // And the field has a value
47 $concreteLocation = $field->form?->concrete_location?->value
48 ) {
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 text
60 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];

Configure actions

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 administrator
14 'delete', // Default delete button to discard spam reports
15 ])
16 ->scope(fn (Builder $query) => $query->where('status', 'reported'))
17 ->perPage(15)
18 ->bulkActions([ // Set the bulk actions that can be run on selected records
19 BulkAcknowledge::make(),
20 BulkDelegate::make(),
21 ]),
22 
23// My reports = reports assigned to the current user
24Index::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) => $query
39 ->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'),

Buttons

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');

Bulk Actions

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 ... Click to show ...
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(): string
31 {
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): array
44 {
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}

Custom Actions

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 Report
11 </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>

Here's what we have now, but there's more

Mobile support

Not only is Lean fully responsive, it has a dedicated mobile menu and is installable as an app (PWA) on any smartphone or computer.

Sidebar on mobile Create user on mobile Delegate on mobile

Dark mode

Dark mode

Magic modals

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.

Modals

Advanced datatables

Lean has extremely advanced (yet easy to use) filters for most fields. The filters are also fully configurable and customizable.

Filters

Make it local

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(): string
14{
15 return 'Administrátoři';
16}

Custom pages

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}

Ergonomic config file

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],

Preconfigured fields

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');

Publishing Laravel assets is cool, most packages can do that. But can they publish JS and CSS assets?

1. Publish assets

    
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 files
10> css
11Path [All]:
12 [* ] All
13 [base.css ] base.css
14 [buttons.css] buttons.css
15 [inputs.css ] inputs.css
16 [main.css ] main.css
17> buttons.css
18 
19Anything else? (yes/no) [no]:
20> no

2. Make your changes

    
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-transparent
13 text-base font-almost-bold rounded-md
14 focus:outline-none;
15}

3. Recompile using artisan 😎

    
1$ php artisan lean:build
2ℹ Compiling Mix
3Laravel Mix v6.0.25
4✔ Compiled Successfully in 21085ms

Perfect Turbolinks integration

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>

Coming soon

Join the waiting list

Lean is launching soon

Join the our waiting list to be notified when Lean is released

FAQ

Questions & Answers

As much as you want. You can change field views, field behavior, CRUD actions (e.g. custom CreateOrder action), language strings, pieces of the layout, create custom Pages when CRUD resources aren't enough, and much more.
Yes. Lean lets you translate the global language strings as well as every language string individually for each resource.
Lean is based on Livewire, Tailwind CSS, and Alpine.js. This stack lets anyone be productive with it. Knowing Blade is all you need to customize the views.

Lean uses cutting-edge framework features, so only Laravel 8 is supported. It also requires PHP 8.0.
The package's API is very expressive (see the docs for yourself) and very strongly typed. Basically, anything that can be typed — is typed. This saves both us & you a lot of time fixing strange bugs, because most things that would've caused issues get caught by psalm or PHPStan.

The code is aggressively tested with PHPUnit, psalm, php-cs-fixer, and PHPStan in our CI pipeline.
The price will be announced in the email sent on launch day. As for licensing, Lean uses yearly licenses in the form of one-time purchases. In other words, you'll pay for the license and will receive updates for one year. After the license expires, you'll be able to keep using the last version that was released while your license was active. If you extend your license, you'll get another year of updates.
The app whose code was used in the examples above will be a public GitHub repository when Lean is launched.