mimisk / pinakas
A simple, reusable Laravel table builder package for fast CRUD listings.
Requires
- php: ^8.2
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0|^4.0
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^2.36|^3.0|^4.0
- pestphp/pest-plugin-laravel: ^2.4|^3.0|^4.0
- phpunit/phpunit: ^10.5|^11.5|^12.5
README
Pinakas is a simple, reusable Laravel table builder package focused on fast CRUD listings.
Requirements
- PHP
^8.2 - Laravel
^11.0,^12.0, or^13.0
Installation
Via Composer
composer require mimisk/pinakas
Install JS dependencies for the interactive controls:
npm install alpinejs @tailwindplus/elements
Publish package JS asset:
php artisan vendor:publish --tag=pinakas-assets
Import Alpine and Pinakas JS in your app entrypoint (resources/js/app.js):
import Alpine from 'alpinejs'; import './vendor/pinakas/pinakas'; window.Alpine = Alpine; Alpine.start();
Rebuild assets:
npm run build
If your app already starts Alpine (for example through Breeze), only import the published Pinakas JS asset once:
import './vendor/pinakas/pinakas';
For Vite dev servers using a custom local domain, make sure HMR uses the same lowercase host as the browser URL. Example:
// vite.config.js export default defineConfig({ server: { hmr: { host: 'packages.test', }, watch: { ignored: [ '**/vendor/**', '**/packages/**/vendor/**', '**/storage/debugbar/**', '**/public/build/**', ], }, }, });
Configuration
Publish config:
php artisan vendor:publish --tag=pinakas-config
config/pinakas.php:
'empty_state' => [ 'title' => 'No records found', 'description' => 'There are no rows available yet.', 'search_title' => 'No matching results', 'search_description' => 'Try a different keyword or clear your search.', ], 'search' => [ 'enabled' => false, 'query_name' => 'search', 'show_label' => true, 'label' => 'Search', 'placeholder' => 'Search...', 'rounded' => 'rounded-none', 'debounce_ms' => 350, 'min_chars' => 3, 'icon' => 'magnifying-glass', ], 'sorting' => [ 'enabled' => false, 'query_name' => 'sort', 'direction_query_name' => 'direction', 'default_direction' => 'asc', 'icon_position' => 'right', // left | right ], 'bulk' => [ 'selected_input_name' => 'selected_ids', 'actions' => [], ], 'ui' => [ 'accent_color' => 'amber-600', 'table_bordered' => false, 'table_rounded' => 'rounded-xs', 'table_striped' => false, 'table_hoverable' => true, 'pagination_dropdown_rounded' => 'rounded-none', 'action_button_rounded' => 'rounded-none', 'action_dropdown_rounded' => 'rounded-none', ], 'columns' => [ 'date_format' => 'd-m-Y', 'time_format' => 'H:i', ], 'pagination' => [ 'enabled' => false, 'default_per_page' => 15, 'page_name' => 'page', 'per_page_query_name' => 'per_page', 'per_page_options' => [10, 25, 50], 'show_label' => false, // Available: default, centered-page-numbers 'template' => 'default', ],
These are global defaults and can be overridden per table.
Usage
Quick Start
use App\Models\User; use Mimisk\Pinakas\Actions\ActionGroup; use Mimisk\Pinakas\Actions\DeleteAction; use Mimisk\Pinakas\Actions\EditAction; use Mimisk\Pinakas\Actions\ViewAction; use Mimisk\Pinakas\Bulk\BulkAction; use Mimisk\Pinakas\Columns\BadgeColumn; use Mimisk\Pinakas\Columns\BooleanColumn; use Mimisk\Pinakas\Columns\Column; use Mimisk\Pinakas\Columns\DateColumn; use Mimisk\Pinakas\Columns\TimeColumn; use Mimisk\Pinakas\Pinakas; $table = (new Pinakas()) ->model(User::class) ->columns([ Column::make('Id', 'id')->searchable()->sortable(), Column::make('Name', 'name')->searchable()->sortable(), Column::make('Email', 'email')->searchable()->sortable(), DateColumn::make('Created At', 'created_at')->format('d/m/Y H:i')->sortable(), ]) ->actions([ ActionGroup::make([ ViewAction::make(), EditAction::make(), DeleteAction::make(), ]), ]) ->bulkActions([ BulkAction::make('Delete Selected') ->icon('trash') ->url('/users/bulk-delete') ->method('DELETE') ->confirm('Delete selected users?'), ]) ->paginate(10) ->searchable() ->sortable() ->uiAccentColor('amber-600') ->bordered(true) ->tableRounded('rounded-xs') ->paginationDropdownRounded('rounded-none') ->actionButtonRounded('rounded-none') ->actionDropdownRounded('rounded-none') ->perPageOptions([10, 25, 50]);
Rendering The Table View
Pass the table object to your Blade view:
return view('welcome', [ 'table' => $table, ]);
Render the package table in Blade:
@include('pinakas::table', ['table' => $table])
Columns
Define columns with label + model attribute:
->columns([ Column::make('Id', 'id'), Column::make('Name', 'name'), Column::make('Email', 'email'), Column::make('Created At', 'created_at'), ])
Column alignment:
->columns([ Column::make('Id', 'id')->align('center'), // header + cells Column::make('Name', 'name')->align('left'), Column::make('Amount', 'amount')->align('right'), ])
Header alignment (independent from cell alignment):
->columns([ Column::make('Amount', 'amount') ->cellAlign('right') // cells only ->headerAlign('center') // header ])
Header label is optional in make(). If omitted, it is auto-generated
from the attribute:
->columns([ Column::make(attribute: 'created_at'), // "Created At" DateColumn::make(attribute: 'email_verified_at'), // "Email Verified At" ])
Date column type:
use Mimisk\Pinakas\Columns\DateColumn; ->columns([ DateColumn::make('Created At', 'created_at') ->format('d/m/Y H:i') ->timezone('Europe/Athens') ->emptyText('-'), ])
If ->format(...) is omitted, DateColumn uses columns.date_format from config.
Time column type:
use Mimisk\Pinakas\Columns\TimeColumn; ->columns([ TimeColumn::make('Login Time', 'last_login_at') ->format('H:i') ->timezone('Europe/Athens') ->emptyText('-'), ])
If ->format(...) is omitted, TimeColumn uses columns.time_format from config.
Badge column type:
use Mimisk\Pinakas\Columns\BadgeColumn; ->columns([ BadgeColumn::make('Status', 'status') ->color(fn ($value) => filled($value) ? 'green' : 'gray'), ])
Boolean column type:
use Mimisk\Pinakas\Columns\BooleanColumn; ->columns([ BooleanColumn::make('Verified', 'is_verified') ->labels('Verified', 'Not verified') ->colors('green', 'gray'), ])
Actions (Single And Group)
Single actions:
->actions([ ViewAction::make(), EditAction::make(), DeleteAction::make(), ])
Grouped actions:
->actions([ ActionGroup::make([ ViewAction::make(), EditAction::make(), DeleteAction::make(), ]), ])
Custom action URLs may be generated directly or through named routes:
Action::make('Open') ->route('user.show', fn ($row) => ['user' => $row->id])
Destructive actions can show a confirmation modal before submit:
Action::make('Delete') ->url('/users/1') ->method('DELETE') ->confirm('Delete this user?')
DeleteAction::make() uses the Laravel-style *.destroy route name.
Confirmation modals are rendered by pinakas::partials.confirm-modal.
Pinakas also ships pinakas::partials.styles for the required x-cloak
rule, preventing Alpine dropdown/modal flashes while the page initializes.
Pagination
Enable pagination:
->paginate(10)
Second parameter defines page query key:
->paginate(10, 'users_page')
Third parameter is optional label text (shown above dropdown):
->paginate(10, 'users_page', 'Per Page')
Pagination template override per table:
->paginationTemplate('centered-page-numbers')
You can also pass a direct view name:
->paginationTemplate('my-custom.pagination')
Search
Enable global search:
->searchable()
Custom search query key:
->searchable('q')
Override label / placeholder / icon per table:
->searchLabel('Find User') ->searchPlaceholder('Type name or email') ->searchRounded('rounded-none') ->searchDebounceMs(300) ->searchMinChars(3) ->searchIcon('magnifying-glass') // or 'search', null, or custom view name
Hide label or icon:
->showSearchLabel(false) ->searchIcon(null)
Mark specific columns as searchable:
->columns([ Column::make('Name', 'name')->searchable(), Column::make('Email', 'email')->searchable(), Column::make('Created At', 'created_at'), // not searchable ])
If no columns are marked, search is applied to all defined column attributes.
Sorting
Enable sorting support for this table:
->sortable()
Set icon position per table (override config):
->sortIconPosition('right') // or 'left'
Custom sort query keys:
->sortable('sort_by', 'sort_direction')
Mark sortable columns:
->columns([ Column::make('Id', 'id')->sortable(), Column::make('Name', 'name')->sortable(), Column::make('Email', 'email')->sortable(), ])
You can combine ->searchable() and ->sortable() safely.
Only columns marked with ->sortable() become sortable.
Bulk Actions
Register bulk actions:
use Mimisk\Pinakas\Bulk\BulkAction; ->bulkActions([ BulkAction::make('Delete Selected') ->icon('trash') ->url('/users/bulk-delete') ->method('DELETE') ->confirm('Delete selected users?'), ])
Customize selected IDs input name:
->bulkSelectedInputName('ids')
Controller endpoint example:
public function bulkDelete(Request $request) { $ids = $request->input('selected_ids', []); User::query()->whereIn('id', $ids)->delete(); return back(); }
UI Settings
Override global accent color per table:
->uiAccentColor('amber-600')
You may also pass CSS color values:
->uiAccentColor('#d97706')
Control outer table border per table:
->bordered(true) // show border ->bordered(false) // hide border
Control table rounded class per table:
->tableRounded('rounded-xs') ->tableRounded('rounded-lg') ->tableRounded('rounded-none')
Control striped rows per table:
->striped() // ON ->striped(false) // OFF
Control hoverable rows per table:
->hoverable() // ON ->hoverable(false) // OFF
Control rounded classes for controls:
->paginationDropdownRounded('rounded-none') ->actionButtonRounded('rounded-none') ->actionDropdownRounded('rounded-none')
Per-Page Selector
Per page dropdown appears automatically when pagination is enabled.
No label is shown by default.
el-select is powered by @tailwindplus/elements (npm).
->perPageOptions([10, 25, 50])
Custom per-page query key:
->perPageOptions([10, 25, 50], 'users_per_page')
Label visibility is controlled globally by pagination.show_label.
If show_label = true and no custom text is passed, default text is Per page.
Loading State
The table includes a built-in loading overlay (spinner + reduced opacity) while form submissions and link navigations are in progress. The UI classes include dark mode variants for table, controls, and loading state.
Empty States
When the table has no data:
->emptyState('No users yet', 'Create your first user to get started.')
When search has no matches:
->searchEmptyState('No users found', 'Try another term or clear search.')
Full Controller Example
<?php namespace App\Http\Controllers; use App\Models\User; use Mimisk\Pinakas\Actions\ActionGroup; use Mimisk\Pinakas\Actions\DeleteAction; use Mimisk\Pinakas\Actions\EditAction; use Mimisk\Pinakas\Actions\ViewAction; use Mimisk\Pinakas\Bulk\BulkAction; use Mimisk\Pinakas\Columns\Column; use Mimisk\Pinakas\Pinakas; class TestController extends Controller { public function showTable() { $table = (new Pinakas()) ->model(User::class) ->columns([ Column::make('Id', 'id')->searchable()->sortable(), Column::make('Name', 'name')->searchable()->sortable(), Column::make('Email', 'email')->searchable()->sortable(), Column::make('Created At', 'created_at'), ]) ->actions([ ActionGroup::make([ ViewAction::make(), EditAction::make(), DeleteAction::make(), ]), ]) ->bulkActions([ BulkAction::make('Delete Selected') ->icon('trash') ->url('/users/bulk-delete') ->method('DELETE') ->confirm('Delete selected users?'), ]) ->paginate(10) ->searchable() ->sortable() ->perPageOptions([10, 25, 50]); return view('welcome', [ 'table' => $table, ]); } }
Notes
Route naming convention used by default actions:
user.showuser.edituser.destroy
These are inferred from each row model class (example: App\Models\User -> user.*).
Change log
Please see the CHANGELOG for more information on what has changed recently.
Testing
composer install
composer test
Static analysis is handled by Larastan:
composer analyse
The GitHub workflow runs both Pest and Larastan against the supported Laravel matrix.
Security
If you discover any security related issues, please email mimisk88@gmail.com instead of using the issue tracker.
Credits
License
MIT. Please see the LICENSE file for more information.
