Lists
Quickly generate powerful, feature-rich data listings with sorting, searching, filtering, pagination, and more.
Getting Started
Let's start with the absolute minimum. In your controller, create a method that returns a Listing, then use the Props class to expose it to your Inertia component in the rendering method. The Listing method must have the #[Lists\Expose] attribute.
use Hewcode\Hewcode\Props;
use Hewcode\Hewcode\Lists;
class PostController extends Controller
{
public function index(): Response
{
return Inertia::render('posts/index', Props\Props::for($this)
->components(['posts'])
);
}
#[Lists\Expose]
public function posts(): Lists\Listing
{
return Lists\Listing::make()
->visible(auth()->user()?->can('manage-posts') ?? false) // Required
->query(Post::query())
->columns([
Lists\Schema\TextColumn::make('title'),
Lists\Schema\TextColumn::make('status'),
]);
}
}DANGER
Remember to always make sure the visibility of actions is properly set using authorization checks to prevent unauthorized access. Assume that controller middleware and controller method checks are not sufficient.
On the frontend, just spread the props:
import DataTable from '@hewcode/react/components/data-table/DataTable';
import { usePage } from '@inertiajs/react';
export default function Index() {
const { posts } = usePage().props;
return <DataTable {...posts} />;
}That's it.
Listing Definition
You can define a Listing definition class that allows you to reuse the same listing in multiple places.
Create a listing definition using the command:
php artisan hew:listing UserListing --model=User --form=UserForm --generateYou can pass:
--model=Userto specify the model explicitly.--form=UserFormto associate a form definition with the listing.--generateto auto-generate listing columns based on your model's table structure.
This will create a class that extends Hewcode\Hewcode\Lists\ListingDefinition:
use App\Models\User;
use Hewcode\Hewcode\Actions;
use Hewcode\Hewcode\Lists;
class UserListing extends Lists\ListingDefinition
{
protected string $model = User::class;
protected ?string $form = UserForm::class;
public function default(Lists\Listing $listing): Lists\Listing
{
return $listing
->visible()
->columns([
Lists\Schema\TextColumn::make('id'),
Lists\Schema\TextColumn::make('name')
->sortable()
->searchable(),
Lists\Schema\TextColumn::make('email')
->searchable()
->wrap(),
Lists\Schema\TextColumn::make('posts_count')
->label('Posts')
->sortable(),
])
->actions([
Actions\Eloquent\EditAction::make(),
])
->defaultSort('created_at', 'desc')
->perPage(25);
}
}The $form is optional and refers to a Form definition class that will be used for editing records. See the Forms documentation for more details.
You can then use this Listing definition in your controller:
#[Lists\Expose]
public function users(): Lists\Listing
{
return UserListing::make('users');
}You can pass an additional context parameter which will use a different method than default():
#[Lists\Expose]
public function admins(): Lists\Listing
{
return UserListing::make('admins', context: 'administration');
}
// In UserListing.php
class UserListing extends Lists\ListingDefinition
{
// ...
public function administration(Lists\Listing $listing): Lists\Listing
{
return $listing
->visible()
->query(User::query()->where('is_admin', true))
->columns([
// ...
]);
}
}Essentials
Pagination
Control how many items appear per page. Users can typically change this in the UI, but this sets the default.
->perPage(20) // Default: 15Default Sorting
Set which column is sorted by default and in which direction. Users can still change the sort by clicking column headers, but this sets the initial state.
->defaultSort('created_at', 'desc') // Second param defaults to 'asc'Relationships
Listing Records from a Relationship
To display a model's related records (e.g., a user's posts), use the ->relationship() method. This requires providing the owner record either through Props or explicitly:
Using Props with record()
// Controller
public function edit(User $user): Response
{
return Inertia::render('users/edit', Props\Props::for($this)
->record($user) // Sets the owner record for relationship listings
->components(['form', 'posts'])
);
}
#[Lists\Expose]
public function posts(): Lists\Listing
{
return Lists\Listing::make()
->visible(auth()->check())
->relationship('posts') // Uses the record from Props
->columns([
Lists\Schema\TextColumn::make('title')->sortable()->searchable(),
Lists\Schema\TextColumn::make('status')->badge(),
Lists\Schema\TextColumn::make('published_at')->date(),
])
->defaultSort('created_at', 'desc');
}Explicit owner record
#[Lists\Expose]
public function posts(): Lists\Listing
{
$user = User::find(request()->route('user'));
return Lists\Listing::make()
->visible(auth()->check())
->ownerRecord($user) // Explicitly set the owner
->relationship('posts') // Uses the explicitly set owner
->columns([
Lists\Schema\TextColumn::make('title')->sortable()->searchable(),
Lists\Schema\TextColumn::make('status')->badge(),
Lists\Schema\TextColumn::make('published_at')->date(),
])
->defaultSort('created_at', 'desc');
}Custom relationship queries
You can modify the relationship query by passing a closure as the second parameter:
#[Lists\Expose]
public function posts(): Lists\Listing
{
return Lists\Listing::make()
->visible(auth()->check())
->relationship('posts', fn (Builder $query) =>
$query->where('status', PostStatus::PUBLISHED)
->with('category')
->orderBy('featured', 'desc')
)
->columns([
Lists\Schema\TextColumn::make('title')->sortable()->searchable(),
Lists\Schema\TextColumn::make('category.name')->badge(),
Lists\Schema\TextColumn::make('published_at')->date(),
]);
}This allows you to filter, eager load additional relationships, or apply custom sorting to the relationship query while still maintaining the automatic scoping to the owner record.
Columns
You can declare columns to display in the listing, the column name corresponds to the model attribute.
Labels are automatically generated from your locale files if no explicit label is provided. See Automatic Locale Labels for more details.
->columns([
TextColumn::make('id'),
TextColumn::make('title'), // default label: __('app.posts.columns.title')
TextColumn::make('user.name')
->label('Author'),
])Visibility
You can control whether a column is visible using ->visible() or ->hidden():
TextColumn::make('internal_notes')
->visible(false) // Always hidden,
TextColumn::make('admin_notes')
->hidden(fn () => ! auth()->user()->isAdmin())Omission
You can omit a cell value conditionally using ->omit():
TextColumn::make('email')
->omit(fn (User $record) => ! auth()->user()->canViewEmails()),
TextColumn::make('salary')
->omit(fn (User $record) => $record->department !== 'HR')INFO
The omit() method differs from visible() in that:
visible()controls whether the entire column appears for all recordsomit()controls whether the value is shown for individual records, returningnullwhen the condition is met
Formatting
You can format column values using ->formatStateUsing():
TextColumn::make('price')
->formatStateUsing(fn ($state) => '$' . number_format($state, 2))Limiting Text Length
Truncate long text to a specified number of characters:
TextColumn::make('description')
->limit(100), // Truncate to 100 characters with '...'
TextColumn::make('content')
->limit(50, '…'), // Custom suffixDatetime Columns
Using the ->date() or ->datetime() methods will format the column value appropriately. This will use the default configured format which can be customized as described in the Configuration docs. You can also pass a custom format string.
These methods also allow using relative display.
TextColumn::make('published_at')
->date(),
TextColumn::make('created_at')
->datetime(format: 'M j, Y g:i A'),
TextColumn::make('updated_at')
->datetime(relative: true),Sortable & Searchable
You can make columns sortable and searchable:
->columns([
Lists\Schema\TextColumn::make('title')
->sortable()
->searchable(),
Lists\Schema\TextColumn::make('status')
->sortable(),
]);Wrapping Text
You can enable text wrapping for long content:
TextColumn::make('content')
->wrap(), // Enable text wrappingColor
Badge Columns
You can display column values as badges for better visual distinction. If using a custom color, the badge will use that color.
TextColumn::make('status')
->badge() // Default variant
TextColumn::make('user.name')
->badge(true, 'outline') // With specific variant
TextColumn::make('category.name')
->badge(true, 'secondary')Color
You can customize the color of text or badges with a static value or dynamically based on the record's data.
use Hewcode\Hewcode\Support\Enums\Color;
// Static color with enum (recommended)
TextColumn::make('priority')
->badge()
->color(Color::DANGER);
// Static color with string
TextColumn::make('priority')
->badge()
->color('danger');
// Dynamic color from record data
TextColumn::make('category.name')
->badge()
->color(fn ($record) => $record->category->color);
// Dynamic color with conditional logic using enums
TextColumn::make('status')
->badge()
->color(fn ($record) => match ($record->status) {
PostStatus::DRAFT => Color::WARNING,
PostStatus::PUBLISHED => Color::SUCCESS,
default => Color::SECONDARY,
});
// Dynamic color with strings
TextColumn::make('status')
->color(fn ($record) => match ($record->status) {
PostStatus::DRAFT => 'warning',
PostStatus::PUBLISHED => 'success',
default => 'secondary',
});TIP
Use the Hewcode\Hewcode\Support\Enums\Color enum for type-safe color values.
Icons
You can add icons to columns, either static or dynamic based on the record's data. When using badges, the icon appears inside the badge.
TextColumn::make('email')
->icon('lucide-mail'),
TextColumn::make('status')
->badge()
->icon(fn($record) => match($record->status) {
PostStatus::PUBLISHED => 'lucide-circle-check',
PostStatus::DRAFT => 'lucide-pencil',
PostStatus::ARCHIVED => 'lucide-archive',
default => null,
})You can also customize the icon position and size:
TextColumn::make('priority')
->icon('lucide-flag', position: 'after', size: 20)TIP
Browse available icons at lucide.dev/icons. All icons must use the lucide- prefix (e.g., lucide-circle-check, lucide-pencil).
After & Before content
You can add additional content before or after the cell value. This can be static text or dynamic based on the record's data. You can also use badges here.
use Hewcode\Hewcode\Fragments\Badge;
TextColumn::make('title')
->after(fn ($record) => $record->slug)
TextColumn::make('name')
->before(fn (User $record) => Badge::make('Admin'))TextColumn::make('title')
->after(fn (Post $record) =>
Badge::make(
label: $record->status->getLabel(),
color: match ($record->status) {
PostStatus::PUBLISHED => 'success',
PostStatus::DRAFT => 'warning',
PostStatus::ARCHIVED => 'secondary',
},
icon: match ($record->status) {
PostStatus::PUBLISHED => 'lucide-circle-check',
PostStatus::DRAFT => 'lucide-pencil',
PostStatus::ARCHIVED => 'lucide-archive',
default => null,
},
variant: 'secondary',
)
)Togglable Columns
You can allow users to show or hide specific columns using the ->togglable() method. This will display a popover menu where users can toggle column visibility.
TextColumn::make('content')
->searchable()
->wrap()
->togglable(),
TextColumn::make('created_at')
->togglable(isToggledHiddenByDefault: true),Relationships
You can display related model data using dot notation in column definitions:
// Single relationship
TextColumn::make('user.name')
->label('Author')
// Nested relationships
TextColumn::make('category.parent.name')
->label('Parent Category')WARNING
Make sure to eager load relationships in your query.
->query(Post::query()->with(['user', 'category.parent']))Filters
You can specify filters to allow users to narrow down results. Filters appear in a popover panel above the table.
use Hewcode\Hewcode\Lists\Filters;
->filters([
Lists\Filters\SelectFilter::make('status')
->label('Status')
->options(PostStatus::class), // Enum or array
Lists\Filters\SelectFilter::make('category')
->label('Category')
->field('category_id') // Which DB field to filter
->options(Category::pluck('name', 'id')->toArray())
->multiple() // Allow multiple selections
->searchable(), // Add search to dropdown
Lists\Filters\DateRangeFilter::make('published_date')
->label('Published Date')
->field('published_at'),
])Relationship filters
You can also create filters that load options from related models using the ->relationship() method:
Lists\Filters\SelectFilter::make('category')
->label('Category')
->field('category_id')
->relationship('category', 'name') // Load from category relationship
->searchable()
->multiple()
->preload() // Load first 25 options immediatelyThis works identically to form relationship fields, including:
- Automatic option loading from the related model
- Real-time search as users type
- Custom query modification for scoping
Custom relationship query:
Customize the relationship query using a closure:
->relationship(
relationshipName: 'author',
titleColumn: 'name',
modifyQueryUsing: fn ($query) => $query->where('active', true)
)Deferred Filtering
By default, filters apply immediately as users change them. For complex queries or large datasets, you may want to defer filter application until the user explicitly clicks "Apply Filters":
->filters([
Lists\Filters\SelectFilter::make('status')
->label('Status')
->options(PostStatus::class),
Lists\Filters\SelectFilter::make('category')
->label('Category')
->field('category_id')
->options(Category::pluck('name', 'id')->toArray()),
])
->deferFiltering() // Add "Apply Filters" buttonInline Filters
Display filters inline below the table header instead of in a popover:
->inlineFilters()Advanced Features
These features handle more complex use cases.
Tabs
Tabs provide quick navigation between different data views, perfect for status-based filtering or common queries:
use Hewcode\Hewcode\Lists;
use Illuminate\Database\Eloquent\Builder;
->tabs([
Lists\Tabs\Tab::make('all')
->label('All Posts')
->badge(fn () => Post::count()), // Show count in badge
Lists\Tabs\Tab::make('published')
->label('Published')
->badge(fn () => Post::where('status', PostStatus::PUBLISHED)->count())
->query(fn (Builder $query) => $query->where('status', PostStatus::PUBLISHED)),
Lists\Tabs\Tab::make('drafts')
->label('Drafts')
->badge(fn () => Post::where('status', PostStatus::DRAFT)->count())
->query(fn (Builder $query) => $query->where('status', PostStatus::DRAFT)),
])
->defaultTab('all') // Which tab is active by defaultHow tabs work:
- Each tab can modify the base query with the
query()callback - Badges update dynamically and show counts or other metrics
- The default tab is active when no tab parameter is in the request
- Tab state persists based on your persistence configuration
Row Background Colors
Add visual indicators with conditional row styling:
use Hewcode\Hewcode\Support\Enums\Color;
// With Color enum (recommended)
->bgColor(fn ($record) => match ($record->status) {
PostStatus::DRAFT => Color::WARNING,
PostStatus::PUBLISHED => Color::SUCCESS,
PostStatus::ARCHIVED => Color::SECONDARY,
default => null,
})
// With strings
->bgColor(fn ($record) => match ($record->status) {
PostStatus::DRAFT => 'warning', // Yellow background
PostStatus::PUBLISHED => 'success', // Green background
PostStatus::ARCHIVED => 'secondary', // Gray background
default => null, // No special color
})TIP
Use the Hewcode\Hewcode\Support\Enums\Color enum for type-safe color values.
Clickable Rows
Make entire table rows clickable to navigate to a URL or trigger an action.
Navigate to a URL
->recordUrl(fn (Post $record) => route('posts.edit', $record))Trigger a Row Action
Instead of navigating to a URL, you can trigger a specific row action when clicking anywhere on the row:
->recordAction('edit')
->actions([
Actions\Eloquent\EditAction::make(),
])
// or
->recordAction('custom')
->actions([
Actions\Action::make('custom')
->label('Custom Action')
->action(fn (Post $record) => /* your logic here */),
])The action name must match one of your row actions. When the row is clicked, it triggers the action exactly as if the user clicked the action button—including confirmation modals, forms, and all other action behavior.
Reordering
Enable users to reorder records visually:
->reorderable('order') // Column name that stores the orderRequirements:
- Your model must have an integer column (e.g.,
order,position,sort_order) - The column should have a default value (typically
0) - Only works with Eloquent queries (not iterable data)
Example migration:
$table->integer('order')->default(0);When reordering is enabled, users can drag rows to reposition them, and the order column updates automatically.
Actions
You can attach actions to listings for both individual rows and bulk operations.
For more details about building actions, see the Actions documentation.
Row Actions
Add action buttons that appear on each row, operating on the specific record:
use Hewcode\Hewcode\Actions;
->actions([
Actions\Action::make('duplicate')
->label('Duplicate')
->color('secondary')
->action(fn (Post $record) => $record->replicate()->save())
])You can use some predefined Eloquent actions for Editing, Deleting, and Restoring records:
use Hewcode\Hewcode\Actions;
use Hewcode\Hewcode\Forms;
->actions([
Actions\Eloquent\EditAction::make()
->form([
Forms\Schema\TextInput::make('title')
->label('Title')
->required()
->maxLength(255),
Forms\Schema\Textarea::make('content')
->label('Content')
->rows(8)
->required(),
Forms\Schema\Select::make('status')
->label('Status')
->options(PostStatus::class)
->required(),
])
->visible(fn (Post $record) => auth()->user()->can('update', $record)),
Actions\Eloquent\DeleteAction::make()
->visible(fn (Post $record) => auth()->user()->can('delete', $record)),
Actions\Eloquent\RestoreAction::make()
->visible(fn (Post $record) => $record->trashed()),
])TIP
Row actions automatically receive the $record parameter and form data as $data if a form is defined.
Bulk Actions
You can let users select multiple rows and perform batch operations:
use Hewcode\Hewcode\Actions;
use Hewcode\Hewcode\Toasts\Toast;
use Illuminate\Support\Collection;
->bulkActions([
Actions\BulkAction::make('publish')
->label('Publish Selected')
->color('primary')
->action(function (Collection $records) {
$count = $records->count();
$records->each->update([
'status' => PostStatus::PUBLISHED,
'published_at' => now(),
]);
Toast::make()
->title("Published $count Posts")
->success()
->send();
}),
Actions\BulkAction::make('export')
->label('Export Selected')
->color('secondary')
->action(function (Collection $records) {
$filename = 'posts-export-' . now()->format('Y-m-d') . '.csv';
return response()->streamDownload(function () use ($records) {
$file = fopen('php://output', 'w');
fputcsv($file, ['ID', 'Title', 'Status', 'Created']);
foreach ($records as $record) {
fputcsv($file, [
$record->id,
$record->title,
$record->status->value,
$record->created_at->format('Y-m-d'),
]);
}
fclose($file);
}, $filename);
}),
Actions\BulkAction::make('change_status')
->label('Change Status')
->color('warning')
->form([
Forms\Schema\Select::make('status')
->label('New Status')
->options(PostStatus::class)
->required(),
])
->action(function (Collection $records, array $data) {
$count = $records->count();
$status = PostStatus::from($data['status']);
$records->each->update(['status' => $status]);
Toast::make()
->title("Updated $count Posts")
->message("Status changed to {$status->getLabel()}")
->success()
->send();
}),
])TIP
Bulk actions receive a Collection of selected records as $records, and form data as $data if a form is defined.
You can also use predefined Eloquent bulk actions for Deleting and Restoring records:
use Hewcode\Hewcode\Actions;
->bulkActions([
Actions\Eloquent\BulkDeleteAction::make(),
Actions\Eloquent\BulkDeleteAction::make()
->forceDelete(),
Actions\Eloquent\BulkRestoreAction::make(),
])State Persistence
Listings can remember user preferences so they don't lose their filters, sorts, or search terms when navigating away. You have two persistence strategies: URL and session.
Understanding Persistence Strategies
| Feature | URL Persistence | Session Persistence | No Persistence |
|---|---|---|---|
| Shareable state | ✅ Yes | ❌ No | ❌ No |
| Bookmarkable | ✅ Yes | ❌ No | ❌ No |
| Private to user | ❌ No | ✅ Yes | ✅ Yes |
| Clean URLs | ❌ No | ✅ Yes | ✅ Yes |
| Survives restart | ✅ Yes | ⚠️ Depends on session config | ❌ No |
| Best for | Shareable reports, public listings | User preferences, private tables | Exploratory browsing |
URL Persistence
State is stored in query parameters, making listings shareable and bookmarkable:
// Enable specific features
->persistSearchInUrl() // ?search=laravel
->persistSortInUrl() // ?sort=title&direction=asc
->persistFiltersInUrl() // ?filter[status]=published
->persistColumnsInUrl() // ?columns[content]=false
// Or enable everything at once
->persistInUrl()When to use URL persistence:
- Reports that users share with colleagues
- Public data views where anyone can access the same filtered view
- When you want browser back/forward to work with table state
- Analytics dashboards where filters define different views
Example URL with persistence:
/posts?search=laravel&sort=created_at&direction=desc&filter[status]=published&tab=featuredSession Persistence
State is stored server-side in the user's session, keeping URLs clean:
// Enable specific features
->persistSearchInSession()
->persistSortInSession()
->persistFiltersInSession()
->persistColumnsInSession()
// Or enable everything at once
->persistInSession()
// Optionally customize the session key
->sessionKey('my_custom_key')
->persistInSession()When to use session persistence:
- Personal preference settings (which columns a user likes to see)
- Frequently filtered views where users return to the same filters
- When you want clean URLs without query parameters
- Private user-specific table configurations
How it works: The session key is auto-generated from your controller class and method name, ensuring each listing has unique storage. State persists until the session expires or is cleared.
Mixing Strategies
You can combine URL and session persistence for different features:
->persistSearchInUrl() // Search shows in URL (shareable)
->persistSortInSession() // Sort is private preference
->persistFiltersInSession() // Filters are private preference
->persistColumnsInSession() // Column visibility is privateThis keeps URLs shareable while maintaining private preferences.
Disabling Persistence
By default, nothing persists (providing the cleanest UX for exploratory use). To explicitly disable:
->persistSearchInUrl(false)
->persistSortInSession(false)Context
You can pass additional context to listings that will be passed down to related forms and actions within, Using the ->context() method:
->context('administration') // Pass a simple string context
->context([
'project_id' => request()->route('project'),
]) // Pass an array of context dataIn any closure used in the listing or its related forms/actions, you can access the context via a $context parameter:
use Hewcode\Hewcode\Lists\Schema\TextColumn;
use Hewcode\Hewcode\Support\Context;
TextColumn::make('title')
->after(fn (Context $context) => "Project ID: " . $context['project_id'])Working with Iterable Data
While Listings work best with Eloquent models, you can also use arrays or collections:
#[Lists\Expose]
public function stats(): Lists\Listing
{
$data = [
['name' => 'John Doe', 'email' => '[email protected]', 'role' => 'Admin'],
['name' => 'Jane Smith', 'email' => '[email protected]', 'role' => 'User'],
];
return Lists\Listing::make()
->data($data) // Use ->data() instead of ->query()
->columns([
Lists\Schema\TextColumn::make('name')->sortable()->searchable(),
Lists\Schema\TextColumn::make('email')->searchable(),
Lists\Schema\TextColumn::make('role'),
]);
}Limitations with iterable data:
- No relationship support
- No drag-and-drop reordering
- Less performant for large datasets
For production use with large datasets, always prefer Eloquent models.
Complete Real-World Example
Here's a comprehensive example showing most features working together:
use Hewcode\Hewcode\Props;
use Hewcode\Hewcode\Lists;
use Hewcode\Hewcode\Actions;
use Hewcode\Hewcode\Fragments\Badge;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class PostController extends Controller
{
public function index(): Response
{
return Inertia::render('posts/index', Props\Props::for($this)->components(['posts', 'actions']));
}
#[Lists\Expose]
public function posts(): Lists\Listing
{
return Lists\Listing::make()
->visible(auth()->user()?->can('manage-posts') ?? false) // Required
// Eager load relationships to avoid N+1
->query(Post::query()->with(['user', 'category']))
// Define columns with various features
->columns([
Lists\Schema\TextColumn::make('id')
->togglable(true, true), // Hidden by default
Lists\Schema\TextColumn::make('title')
->sortable()
->searchable()
->after(fn ($record) => Badge::make($record->slug)), // Show slug below
Lists\Schema\TextColumn::make('status')
->sortable()
->badge(true, 'secondary')
->getStateUsing(fn ($record) => $record->status->getLabel())
->color(fn ($record) => match ($record->status) {
PostStatus::DRAFT => 'warning',
PostStatus::PUBLISHED => 'success',
PostStatus::ARCHIVED => 'secondary',
}),
Lists\Schema\TextColumn::make('user.name')
->label('Author')
->sortable()
->searchable()
->badge(variant: 'outline'),
Lists\Schema\TextColumn::make('category.name')
->sortable()
->searchable()
->badge()
->color(fn ($record) => $record->category->color),
Lists\Schema\TextColumn::make('published_at')
->label('Published')
->sortable()
->getStateUsing(fn ($record) =>
$record->published_at?->format('M j, Y')
),
Lists\Schema\TextColumn::make('created_at')
->sortable()
->getStateUsing(fn ($record) =>
$record->created_at->format('M j, Y g:i A')
)
->togglable(isToggledHiddenByDefault: true),
])
// Set default sort
->defaultSort('created_at', 'desc')
// Add status-based row colors
->bgColor(fn ($record) => match ($record->status) {
PostStatus::DRAFT => 'warning',
PostStatus::PUBLISHED => 'success',
default => null,
})
// Add tabs for quick filtering
->tabs([
Lists\Tabs\Tab::make('all')
->label('All')
->badge(fn () => Post::count()),
Lists\Tabs\Tab::make('published')
->label('Published')
->badge(fn () => Post::where('status', PostStatus::PUBLISHED)->count())
->query(fn (Builder $query) =>
$query->where('status', PostStatus::PUBLISHED)
),
Lists\Tabs\Tab::make('drafts')
->label('Drafts')
->badge(fn () => Post::where('status', PostStatus::DRAFT)->count())
->query(fn (Builder $query) =>
$query->where('status', PostStatus::DRAFT)
),
])
->defaultTab('all')
// Add filters
->filters([
Lists\Filters\SelectFilter::make('status')
->label('Status')
->options(PostStatus::class),
Lists\Filters\SelectFilter::make('category')
->label('Category')
->field('category_id')
->relationship('category', 'name')
->searchable()
->multiple()
->preload(),
Lists\Filters\DateRangeFilter::make('published_date')
->label('Published Date')
->field('published_at'),
])
// Add row actions
->actions([
Actions\Eloquent\EditAction::make()
->form([
Forms\Schema\TextInput::make('title')
->label('Title')
->required()
->maxLength(255),
Forms\Schema\Textarea::make('content')
->label('Content')
->rows(8)
->required(),
Forms\Schema\Select::make('status')
->label('Status')
->options(PostStatus::class)
->required(),
]),
Actions\Eloquent\DeleteAction::make(),
Actions\Eloquent\RestoreAction::make()
->visible(fn ($record) => $record->trashed()),
])
// Add bulk actions
->bulkActions([
Actions\Eloquent\BulkDeleteAction::make(),
Actions\Eloquent\BulkDeleteAction::make()
->forceDelete(),
Actions\Eloquent\BulkRestoreAction::make(),
Actions\BulkAction::make('publish')
->label('Publish Selected')
->color('primary')
->action(fn (Collection $records) =>
$records->each->update(['status' => PostStatus::PUBLISHED])
),
])
// Configure pagination
->perPage(15)
// Persist state in session (not URL for clean links)
->persistInSession();
}
#[Actions\Expose]
public function actions(): Actions\Actions
{
return Actions\Actions::make([
Actions\Action::make('create')
->label('New Post')
->color('primary')
->action(fn () => redirect()->route('posts.create')),
])->visible(auth()->user()?->can('manage-posts') ?? false);
}
}For more details about standalone actions, see the Actions documentation.