Saturday, April 12, 2025

Laravel Filament Export-Import CSV Full Tutorial - FREE!


Laravel Filament is a fantastic admin panel framework that makes tasks like exporting and importing data smooth and intuitive. In this guide, we’ll walk through creating a Laravel project from scratch, setting up models, migrations, and relationships, and implementing Filament’s export and import actions. We’ll keep things relaxed, straightforward, and easy to follow while diving into the technical details. Let’s get started!

Setting Up the Laravel Project with Filament

To kick things off, we need a fresh Laravel project with Filament installed. Filament provides a starter kit that sets up an admin panel right out of the box, so let’s use that.

Open your terminal and run the following command to create a new Laravel project named filament-csv:

laravel new filament-csv --filament

This command uses Laravel’s installer to create a project and includes the Filament starter kit. It’ll take a moment to install all the dependencies, including Filament’s admin panel, which is built on top of Laravel.

Once the installation is complete, navigate into the project directory:

cd filament-csv

Now, let’s open the project in your favorite code editor. For this guide, we’ll assume you’re using something like PHPStorm or VS Code. Run the following to start the Laravel development server:

php artisan serve

You can access the admin panel by visiting http://localhost:8000/admin in your browser. If you don’t see the login page yet, don’t worry—we’ll set up the database and migrations shortly.


Creating Models and Migrations

For this demo, we want to work with two models: Post and Category. A Post will belong to a Category, creating a one-to-many relationship. We’ll also need migrations to define the database tables.

Let’s create the Category model and migration first. Run this Artisan command:

php artisan make:model Category -m

This creates a Category model and a migration file. Open the migration file (located in database/migrations/) and define the categories table with name and slug fields:

Schema::create('categories', function (Blueprint $table) { $table->id(); $table->string('name'); $table->string('slug')->unique(); $table->timestamps(); });

Next, let’s create the Post model and migration:

php artisan make:model Post -m

In the Post migration file, define the table with title, slug, description, and a foreign key for category_id:

Schema::create('posts', function (Blueprint $table) { $table->id(); $table->string('title'); $table->string('slug')->unique(); $table->text('description')->nullable(); $table->foreignId('category_id')->constrained()->onDelete('cascade'); $table->timestamps(); });

The foreignId method creates a foreign key that references the categories table, and onDelete('cascade') ensures that if a category is deleted, its associated posts are removed too.

Now, let’s define the relationships in the models. Open app/Models/Category.php and add a posts relationship:

namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; class Category extends Model { protected $fillable = ['name', 'slug']; public function posts(): HasMany { return $this->hasMany(Post::class); } }

Then, open app/Models/Post.php and define the category relationship:

namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class Post extends Model { protected $fillable = ['title', 'slug', 'description', 'category_id']; public function category(): BelongsTo { return $this->belongsTo(Category::class); } }

With the models and migrations set up, let’s run the migrations to create the tables:

php artisan migrate

You should see output confirming that the categories and posts tables (along with any default Laravel tables like users) have been created.

Setting Up Filament Resources

Filament uses resources to manage models in the admin panel. A resource defines how a model is displayed, edited, and listed. Let’s create resources for Category and Post.

Run these commands to generate the resources:

php artisan make:filament-resource Category php artisan make:filament-resource Post

This creates resource classes in app/Filament/Resources/. Let’s configure them to make the admin panel user-friendly.

Category Resource

Open app/Filament/Resources/CategoryResource.php and update the form and table methods to define the fields:

namespace App\Filament\Resources; use App\Filament\Resources\CategoryResource\Pages; use App\Models\Category; use Filament\Forms; use Filament\Forms\Form; use Filament\Resources\Resource; use Filament\Tables; use Filament\Tables\Table; class CategoryResource extends Resource { protected static ?string $model = Category::class; protected static ?string $navigationIcon = 'heroicon-o-folder'; public static function form(Form $form): Form { return $form ->schema([ Forms\Components\TextInput::make('name') ->required() ->maxLength(255), Forms\Components\TextInput::make('slug') ->required() ->unique(Category::class, 'slug') ->maxLength(255), ]); } public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\TextColumn::make('name')->sortable()->searchable(), Tables\Columns\TextColumn::make('slug')->sortable()->searchable(), Tables\Columns\TextColumn::make('created_at')->dateTime(), ]) ->filters([ // ]) ->actions([ Tables\Actions\EditAction::make(), ]) ->bulkActions([ Tables\Actions\DeleteBulkAction::make(), ]); } public static function getPages(): array { return [ 'index' => Pages\ListCategories::route('/'), 'create' => Pages\CreateCategory::route('/create'), 'edit' => Pages\EditCategory::route('/{record}/edit'), ]; } }

This sets up a form with name and slug fields and a table displaying those fields along with created_at.

Post Resource

Now, open app/Filament/Resources/PostResource.php and configure it to include the relationship with Category:

namespace App\Filament\Resources; use App\Filament\Resources\PostResource\Pages; use App\Models\Post; use Filament\Forms; use Filament\Forms\Form; use Filament\Resources\Resource; use Filament\Tables; use Filament\Tables\Table; class PostResource extends Resource { protected static ?string $model = Post::class; protected static ?string $navigationIcon = 'heroicon-o-document-text'; public static function form(Form $form): Form { return $form ->schema([ Forms\Components\TextInput::make('title') ->required() ->maxLength(255), Forms\Components\TextInput::make('slug') ->required() ->unique(Post::class, 'slug') ->maxLength(255), Forms\Components\Textarea::make('description') ->nullable(), Forms\Components\Select::make('category_id') ->relationship('category', 'name') ->required(), ]); } public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\TextColumn::make('title')->sortable()->searchable(), Tables\Columns\TextColumn::make('slug')->sortable()->searchable(), Tables\Columns\TextColumn::make('category.name')->sortable()->searchable(), Tables\Columns\TextColumn::make('created_at')->dateTime(), ]) ->filters([ Tables\Filters\SelectFilter::make('category') ->relationship('category', 'name'), ]) ->actions([ Tables\Actions\EditAction::make(), ]) ->bulkActions([ Tables\Actions\DeleteBulkAction::make(), ]); } public static function getPages(): array { return [ 'index' => Pages\ListPosts::route('/'), 'create' => Pages\CreatePost::route('/create'), 'edit' => Pages\EditPost::route('/{record}/edit'), ]; } }

Here, we’ve added a Select component for category_id that displays the category’s name and a table column that shows the related category’s name.

Testing the Admin Panel

Before we dive into exporting and importing, let’s make sure everything works. Since Filament requires authentication, create a user by running:

php artisan make:filament-user

Follow the prompts to set up an admin user (e.g., test@example.com with a password). Then, visit http://localhost:8000/admin and log in.

You should see navigation links for Categories and Posts. Try creating a few categories:

  1. Go to Categories > Create.
  2. Add categories like “Tech” (slug: tech), “Lifestyle” (slug: lifestyle), and “Travel” (slug: travel).

Then, create some posts:

  1. Go to Posts > Create.
  2. Add posts with titles, slugs, descriptions, and assign them to categories.

You can also use the table’s search and filter features to verify that everything’s working. For example, filter posts by category to see only those under “Tech.”

Implementing the Export Action

Now, let’s add the ability to export posts as a CSV or Excel file. Filament’s export action makes this a breeze by generating downloadable files based on your model’s data.

Creating the Exporter

First, create an exporter class for the Post model:

php artisan make:filament-exporter Post --generate

The --generate flag automatically creates export columns based on the model’s attributes. Open app/Filament/Exporters/PostExporter.php and inspect the generated code:

namespace App\Filament\Exporters; use App\Models\Post; use Filament\Actions\Exports\ExportColumn; use Filament\Actions\Exports\Exporter; use Filament\Actions\Exports\Models\Export; class PostExporter extends Exporter { protected static ?string $model = Post::class; public static function getColumns(): array { return [ ExportColumn::make('id')->label('ID'), ExportColumn::make('title'), ExportColumn::make('slug'), ExportColumn::make('description'), ExportColumn::make('category_id'), ExportColumn::make('created_at'), ExportColumn::make('updated_at'), ]; } public static function getCompletedNotificationBody(Export $export): string { $body = 'Your post export has completed and ' . number_format($export->successful_rows) . ' ' . str('row')->plural($export->successful_rows) . ' exported.'; if ($failedRowsCount = $export->getFailedRowsCount()) { $body .= ' ' . number_format($failedRowsCount) . ' ' . str('row')->plural($failedRowsCount) . ' failed to export.'; } return $body; } }

The getColumns method defines what data is included in the export. Notice that category_id is included, which is perfect for our needs since it’s a foreign key. However, let’s modify the exporter to include the category’s name for readability in the exported file. Update the category_id column:

ExportColumn::make('category.name')->label('Category'),

This uses Filament’s relationship syntax to export the related category’s name instead of the raw category_id. However, when importing, we’ll need the category_id, so we’ll adjust the importer later to handle this.

Adding the Export Action

To make the export action available, update the PostResource class. Open app/Filament/Resources/PostResource.php and add the export action to the table method’s headerActions:

use Filament\Tables\Actions\ExportAction; use App\Filament\Exporters\PostExporter; public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\TextColumn::make('title')->sortable()->searchable(), Tables\Columns\TextColumn::make('slug')->sortable()->searchable(), Tables\Columns\TextColumn::make('category.name')->sortable()->searchable(), Tables\Columns\TextColumn::make('created_at')->dateTime(), ]) ->filters([ Tables\Filters\SelectFilter::make('category') ->relationship('category', 'name'), ]) ->actions([ Tables\Actions\EditAction::make(), ]) ->bulkActions([ Tables\Actions\DeleteBulkAction::make(), ]) ->headerActions([ ExportAction::make() ->exporter(PostExporter::class), ]); }

This adds an “Export” button to the table header in the admin panel.

Enabling Notifications and Queues

Filament’s export action processes data in the background using Laravel’s queue system. To ensure notifications work, enable database notifications in Filament’s configuration. Open app/Providers/Filament/AdminPanelProvider.php and add:

use Filament\Notifications\Notifications; public function panel(Panel $panel): Panel { return $panel ->default() ->id('admin') ->path('admin') ->login() ->databaseNotifications(); }

Next, set up the queue system. By default, Laravel uses the sync driver, which processes jobs synchronously. For background processing, update .env to use the database driver:

QUEUE_CONNECTION=database

Run the migration to create the jobs table:

php artisan queue:table php artisan migrate

Start the queue worker in a new terminal tab:

php artisan queue:listen

Testing the Export

Go to the Posts page in the admin panel (http://localhost:8000/admin/posts). You should see an “Export” button above the table. Click it, and Filament will prompt you to select columns (by default, all columns from PostExporter are included). Click “Export” to start the process.

You’ll see a notification that the export has begun. Once it’s complete, another notification will provide a download link for the CSV or Excel file. Download the CSV and open it. You should see columns for ID, title, slug, description, Category (showing the category name), created_at, and updated_at.

Implementing the Import Action

Exporting is cool, but what about importing data back into the system? Filament’s import action lets you upload a CSV or Excel file and map its columns to your model’s fields.

Creating the Importer

Generate an importer class:

php artisan make:filament-importer Post --generate

Open app/Filament/Importers/PostImporter.php. The --generate flag creates columns based on the model:

namespace App\Filament\Importers; use App\Models\Post; use Filament\Actions\Imports\ImportColumn; use Filament\Actions\Imports\Importer; use Filament\Actions\Imports\Models\Import; class PostImporter extends Importer { protected static ?string $model = Post::class; public static function getColumns(): array { return [ ImportColumn::make('title') ->requiredMapping(), ImportColumn::make('slug') ->requiredMapping(), ImportColumn::make('description'), ImportColumn::make('category_id') ->requiredMapping(), ImportColumn::make('created_at'), ImportColumn::make('updated_at'), ]; } public function resolveRecord(): ?Post { return new Post(); } public static function getCompletedNotificationBody(Import $import): string { $body = 'Your post import has completed and ' . number_format($import->successful_rows) . ' ' . str('row')->plural($import->successful_rows) . ' imported.'; if ($failedRowsCount = $import->getFailedRowsCount()) { $body .= ' ' . number_format($failedRowsCount) . ' ' . str('row')->plural($failedRowsCount) . ' failed to import.'; } return $body; } }

The category_id column is critical here because the Post model requires a valid category_id. If the CSV contains category names instead, the import will fail. Let’s handle this by mapping the category name to its ID during import.

Update the resolveRecord method to look up the category by name:

use App\Models\Category; public function resolveRecord(): ?Post { $post = new Post(); if ($categoryName = $this->data['category'] ?? null) { $category = Category::where('name', $categoryName)->first(); if ($category) { $this->data['category_id'] = $category->id; } } return $post; }

Also, update the category_id column to accept a category column from the CSV:

ImportColumn::make('category_id') ->guess(['category', 'category_name']) ->requiredMapping(),

This tells Filament to look for a category or category_name column in the CSV and map it to category_id.

Adding the Import Action

Update PostResource to include the import action in headerActions:

use Filament\Tables\Actions\ImportAction; use App\Filament\Importers\PostImporter; public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\TextColumn::make('title')->sortable()->searchable(), Tables\Columns\TextColumn::make('slug')->sortable()->searchable(), Tables\Columns\TextColumn::make('category.name')->sortable()->searchable(), Tables\Columns\TextColumn::make('created_at')->dateTime(), ]) ->filters([ Tables\Filters\SelectFilter::make('category') ->relationship('category', 'name'), ]) ->actions([ Tables\Actions\EditAction::make(), ]) ->bulkActions([ Tables\Actions\DeleteBulkAction::make(), ]) ->headerActions([ ExportAction::make() ->exporter(PostExporter::class), ImportAction::make() ->importer(PostImporter::class), ]); }

Testing the Import

To test the import, let’s use the CSV we exported earlier. First, clear the posts table to simulate a fresh import:

php artisan tinker Post::truncate(); exit

Go to the Posts page in the admin panel and click “Import”. Upload the CSV file you downloaded. Filament will display a mapping interface where you can match CSV columns to model fields. For example:

  • Map the Category column (containing category names) to category_id.
  • Map title, slug, and description to their respective fields.

Click “Import”. The queue worker will process the file, and you’ll get a notification when it’s done. If there are errors (e.g., a category name doesn’t exist), Filament will provide a CSV with details about failed rows.

Check the Posts page, and you should see the imported posts with their correct categories.

Handling Edge Cases

Let’s address a few potential issues to make the export/import process robust.

Exporting Category IDs

The exported CSV includes category names, which is great for readability but problematic for importing since category_id is required. Let’s modify PostExporter to include both category_id and category.name:

public static function getColumns(): array { return [ ExportColumn::make('id')->label('ID'), ExportColumn::make('title'), ExportColumn::make('slug'), ExportColumn::make('description'), ExportColumn::make('category_id')->label('Category ID'), ExportColumn::make('category.name')->label('Category'), ExportColumn::make('created_at'), ExportColumn::make('updated_at'), ]; }

Now, the CSV will include a Category ID column, making imports smoother since you can map it directly to category_id.

Validating Imports

To prevent import failures, add validation rules in PostImporter. Update the getColumns method:

public static function getColumns(): array { return [ ImportColumn::make('title') ->requiredMapping() ->rules(['required', 'max:255']), ImportColumn::make('slug') ->requiredMapping() ->rules(['required', 'max:255', 'unique:posts,slug']), ImportColumn::make('description') ->rules(['nullable']), ImportColumn::make('category_id') ->guess(['category', 'category_name', 'category id']) ->requiredMapping() ->rules(['required', 'exists:categories,id']), ImportColumn::make('created_at') ->rules(['nullable', 'date']), ImportColumn::make('updated_at') ->rules(['nullable', 'date']), ]; }

The exists:categories,id rule ensures that category_id corresponds to an existing category.

Handling Large Imports

For large datasets, you might hit memory or timeout issues. To optimize, configure chunked imports in PostImporter:

protected static int $chunkSize = 100;

This processes the CSV in chunks of 100 rows, reducing memory usage.

Conclusion

Filament’s export and import actions are just the tip of the iceberg. Here are a few other features you might want to explore:

  • Bulk Export: Add ExportBulkAction to bulkActions in PostResource to export selected rows only.
  • Custom Columns: Use ExportColumn::make()->formatStateUsing() to transform data during export (e.g., formatting dates).
  • Import Events: Listen for ImportCompleted or ImportFailed events to trigger custom logic, like sending emails.
  • Progress Tracking: Display a progress bar for imports by enabling Filament’s notification polling.

To dive deeper, check Filament’s official documentation for advanced configurations.

And there you have it—a complete guide to exporting and importing Excel/CSV files with Filament in Laravel! We started by setting up a Laravel project with Filament, created Post and Category models with relationships, and built an admin panel with resources. Then, we implemented export and import actions, handling relationships and edge cases like category mapping.

This setup is perfect for managing content, inventories, or any data that needs to move in and out of your app. Filament’s actions make the process intuitive, and Laravel’s queue system ensures it scales well.

0 comments:

Post a Comment