A modular Laravel export builder around maatwebsite/excel (CSV/XLS/XLSX,PDF)
hasanhawary/export-builder is a Laravel package for a modular laravel export builder around maatwebsite/excel (csv/xls/xlsx,pdf).
It currently has 4 GitHub stars and 672 downloads on Packagist (latest version 2.4.3).
Install it with composer require hasanhawary/export-builder.
Discover more Laravel packages by hasanhawary
or browse all Laravel packages to compare alternatives.
Last updated
A modular Laravel package for building reusable, memory-safe Excel and PDF exports.
Define one export class, get XLSX / XLS / CSV / PDF output, direct download or queued job — with relation support, advanced filters, permission gating, and full translation support.
lazyById() cursor, configurable chunk size, peak memory stays flat regardless of dataset sizewhereIn, whereHas, morph constraints, enum resolvers/export; host routes always win on conflictBaseExportContract for type-hinting custom implementations| Dependency | Version |
|---|---|
| PHP | 8.1 – 8.5 |
| Laravel | 10, 11, 12, 13 |
| maatwebsite/excel | ^3.1 or ^4.0 |
| carlos-meneses/laravel-mpdf | ^2.0 |
composer require hasanhawary/export-builder
Laravel auto-discovers the service provider. No manual registration needed.
Optional publishes:
# Config
php artisan vendor:publish --tag=export-builder-config
# PDF Blade view (customise the template)
php artisan vendor:publish --tag=export-builder-views
# Language files (en + ar)
php artisan vendor:publish --tag=export-builder-lang
# Publishes to: lang/en/export.php and lang/ar/export.php
# Migration for queued export history
php artisan vendor:publish --tag=export-builder-migrations
php artisan migrate
Published lang files land in lang/vendor/export/{en,ar}/export.php.
Create an export class in the configured namespace (default: App\Tools\Export):
namespace App\Tools\Export;
use App\Models\User;
use HasanHawary\ExportBuilder\BaseExport;
class UserExport extends BaseExport
{
public function __construct(array $filter)
{
parent::__construct([
'model' => User::class,
'columns' => [
'id' => 'int',
'name' => 'text',
'email' => 'text',
'is_active' => 'bool',
'created_at' => 'datetime',
],
], $filter);
}
}
page=user resolves to App\Tools\Export\UserExport.
Direct download in a controller:
use HasanHawary\ExportBuilder\ExportBuilder;
public function export(Request $request)
{
return (new ExportBuilder($request->validated()))->response();
}
Via package routes:
GET api/export-direct?page=user&format=xlsx
GET api/export-direct?page=user&format=pdf&start=2026-01-01&end=2026-06-30
POST api/export { "page": "user", "format": "xlsx" } ← queued
GET api/export-log ← history
Publish and edit config/export.php:
return [
// Namespace where export classes live
'namespace' => 'App\\Tools\\Export',
// Translation file for column headings and bool values
// 'export' → package built-in (en + ar) — recommended
// 'my_file' → use your own lang/en/my_file.php
'trans_file' => 'export',
// Rows per chunk for lazyById() streaming (memory-safe)
'chunk_size' => 500,
'pdf' => [
'settings' => [], // static: logo_url, company_name, etc.
'settings_resolver' => null, // callable / invokable / [Class, 'method']
],
'module' => [
'enabled' => true,
'routes' => [
'enabled' => true,
'middleware' => ['api'],
'prefix' => 'api',
'export_path' => 'export',
'direct_path' => 'export-direct',
'log_path' => 'export-log',
'name_prefix' => 'export-builder.export.',
],
'controllers' => [
'direct' => \HasanHawary\ExportBuilder\Http\Controllers\ExportController::class,
'jobs' => \HasanHawary\ExportBuilder\Http\Controllers\ExportJobController::class,
],
'services' => [
'export' => \HasanHawary\ExportBuilder\Services\ExportService::class,
'export_file' => \HasanHawary\ExportBuilder\Services\ExportFileService::class,
'permissions' => \HasanHawary\ExportBuilder\Services\ExportPermissionResolver::class,
],
'storage' => [
'disk' => 'local',
'path' => 'exports',
],
'permissions' => [
'enabled' => false,
'abilities' => [
'export' => 'export',
'queue' => 'create-export-file',
'view_all' => 'view-all-export-file',
'view_own' => 'view-own-export-file',
'delete' => 'delete-export-file',
],
'pages' => [
// Per-page ability overrides:
// 'user' => ['export' => 'export-user', 'queue' => 'queue-user'],
],
],
],
];
| Format | Parameter | Notes |
|---|---|---|
| XLSX | format=xlsx | Default |
| XLS | format=xls | |
| CSV | format=csv | |
| PDF | format=pdf | Uses Blade view |
| Type | Output |
|---|---|
| text | Raw string value |
| int | Cast to integer |
| float | Cast to float (non-numeric returned as-is) |
| money | number_format($v, 2, '.', '') |
| date | YYYY-MM-DD |
| datetime | YYYY-MM-DD HH:MM:SS |
| bool / boolean | Translated Yes / No |
| array | implode(' , ', array_filter($v)) |
| classPath | Class basename, translated if key exists |
| MyEnum::class | Calls MyEnum::resolve($value) |
parent::__construct([
'model' => User::class,
'columns' => ['id' => 'int', 'name' => 'text'],
'relations' => [
// One-to-one / BelongsTo
'one' => [
'role_id' => ['role' => ['name' => 'text']],
],
'many' => [
// Concat all related values into one cell
'concat' => [
'tags' => ['label' => 'text'],
],
// Multi-line block per related item
'list' => [
'addresses' => ['city' => 'text', 'country' => 'text'],
],
// Count with optional alias
'count' => [
'orders as orders_total',
],
],
// Nested — automatically resolved as dot-notation with()
// e.g. 'department_id' => ['department' => ['company' => ['name' => 'text']]]
],
], $filter);
Custom eager loads and selects:
'customWith' => ['settings', 'profile'], // extra with() paths
'customSelect' => ['id', 'name', 'email'], // restrict SELECT columns
'additionalQuery' => [
'posts_count' => fn ($q) => $q->withCount('posts'),
],
customRelations() is the simplest way to export related data without touching the relations config array. Override it in your export class and define columns with closures or attribute names — no schema-level config needed.
class UserExport extends BaseExport
{
public function __construct(array $filter)
{
parent::__construct([
'model' => User::class,
'columns' => ['id' => 'int', 'name' => 'text', 'email' => 'text'],
// No 'relations' key needed — customRelations() handles it below
], $filter);
}
public function customRelations(): array
{
return [
// Simple attribute access
'role' => ['name'],
// Callable — full control over the value
'profile' => [
'full_address' => fn ($profile) => "{$profile->city}, {$profile->country}",
'avatar_url' => fn ($profile) => $profile->avatar ?? 'N/A',
],
// Collection relation — one column per item with an index prefix
'permissions' => ['name'],
];
}
}
How it works:
| Config key | Value type | Output column name |
|---|---|---|
| 'role' => ['name'] | Attribute string | role_name |
| 'profile' => ['full_address' => fn] | Callable | profile_full_address |
| Collection relation | Any attribute | permissions_0_name, permissions_1_name, … |
Rules:
role → $model->role)Collection, each item gets an index prefix (relation_0_key, relation_1_key)relation_columnstrip_tags() is applied to all values automatically_id are automatically removed from the heading rowrelated column filter — clients can request related[]=role_nameCompared to relations config:
| | relations config | customRelations() |
|---|---|---|
| Setup | Declare in constructor array | Override one method |
| Nested relations | Yes (dot-notation) | No |
| Callables | No | Yes |
| Collection indexing | No | Yes |
| Column filter support | Yes | Yes |
| Best for | Standard BelongsTo / HasMany | Custom display logic, computed values |
'relations' => [
'morph' => [
'sourceable_id' => [
'relation' => 'sourceable', // Eloquent morphTo method
'column' => 'name', // Column to display
'type' => 'text', // convertValue type (default: text)
'fallback' => null, // Value when relation is null
],
],
],
Request body:
{
"page": "user",
"format": "xlsx",
"advanced": [
{ "key": "status", "value": ["active", "pending"] },
{ "key": "role_id", "value": 3 }
]
}
Only keys matching actual table columns or configured relation keys are accepted — all others are silently ignored to prevent SQL injection.
Relation filter config:
'filterRelations' => [
'many' => [
'role_id' => ['relation' => 'role', 'column' => 'id'],
// With morph constraint:
'source_id' => [
'relation' => 'sourceable',
'morph' => 'sourceable',
'morph_types' => [Campaign::class, Sponsor::class],
'column' => 'id',
],
],
],
Enum resolver:
protected array $resolvers = [
'status' => ['enum' => StatusEnum::class, 'method' => 'fromLabel'],
];
Clients can request a subset of columns at runtime:
GET api/export-direct?page=user&format=xlsx&columns[]=id&columns[]=name&related[]=roles
Or via JSON:
{
"page": "user",
"format": "xlsx",
"columns": ["id", "name", "email"],
"related": ["roles", "orders_total"]
}
columns filters base columns. related filters relation columns (concat, list, count).
The default Blade view is export::pdf.export (the view name, not a translation key).
Publish to customise:
php artisan vendor:publish --tag=export-builder-views
Override per export class:
public function pdfView(): string
{
return 'exports.my-custom-template';
}
public function pdfData(): array
{
return [
'title' => 'Users Report',
'columns' => array_map(fn ($h) => ['label' => $h, 'width' => 'auto'], $this->headings()),
'rows' => $this->buildQuery()->get()->map(fn ($r) => array_values($this->map($r)))->toArray(),
];
}
PDF settings resolver — resolve dynamic settings (e.g. company logo from DB):
// config/export.php
'pdf' => [
'settings_resolver' => [App\Services\BrandSettings::class, 'forExport'],
// Also supports: invokable class string, Closure
],
The resolver must return an array. Keys available in the Blade view as $settings['logo_url'], $settings['company_name'], etc.
POST api/export
{ "page": "user", "format": "xlsx" }
Response (202 Accepted):
{
"data": {
"id": 1,
"exportable_type": "user",
"format": "xlsx",
"status": "pending",
"file_url": null
},
"message": "Export started successfully."
}
Poll for completion:
GET api/export-log ← list all (paginated, ?per_page=15&status=completed)
GET api/export-log/{id} ← show one
GET api/export-log/{id}/download ← stream the file
DELETE api/export-log/{id} ← soft-delete record and remove stored file
Files are stored on the configured disk:
'storage' => ['disk' => 'local', 'path' => 'exports'],
All package routes default to the api prefix. They never claim a URI already owned by the host app.
| Method | URI | Route name | Description |
|---|---|---|---|
| GET | api/export-direct | export-builder.export.direct | Direct file download |
| GET | api/export | export-builder.export.download | Direct file download (alias) |
| POST | api/export | export-builder.export.store | Create queued export |
| GET | api/export-log | export-builder.export.logs.index | List export history |
| GET | api/export-log/{id} | export-builder.export.logs.show | Show one export record |
| GET | api/export-log/{id}/download | export-builder.export.logs.download | Download exported file |
| DELETE | api/export-log/{id} | export-builder.export.logs.destroy | Delete record and file |
Disable all package routes:
'module' => ['enabled' => false],
Move to a different prefix:
'routes' => [
'prefix' => 'internal/reports',
'name_prefix' => 'reports.',
],
Disabled by default. Enable and configure per-page abilities:
'permissions' => [
'enabled' => true,
'abilities' => [
'export' => 'export',
'queue' => 'create-export-file',
'view_all' => 'view-all-export-file',
'view_own' => 'view-own-export-file',
'delete' => 'delete-export-file',
],
'pages' => [
'user' => [
'export' => 'export-user',
'queue' => 'create-user-export',
],
],
],
Custom resolver — replace the entire permission logic:
'services' => [
'permissions' => App\Services\MyExportPermissionResolver::class,
],
Your class must extend ExportPermissionResolver or implement the same public API (canExport, canCreateQueued, canList, canView, canDelete, scopeForUser).
The package ships with English and Arabic translations for column headings and boolean values.
Published to: lang/{en,ar}/export.php
| Key | English | Arabic |
|---|---|---|
| id | ID | المعرف |
| name | Name | الاسم |
| email | Email | البريد الإلكتروني |
| is_active | Active | نشط |
| created_at | Created At | تاريخ الإنشاء |
| yes | Yes | نعم |
| no | No | لا |
Use your own translation file — point to any file in your project's lang/ directory:
// config/export.php
'trans_file' => 'my_exports', // looks up lang/en/my_exports.php keys
Add custom column translations — add keys to your published lang/vendor/export/en/export.php:
return [
// ...existing keys...
'order_number' => 'Order #',
'total_amount' => 'Total',
];
Every package class can be replaced from config:
'module' => [
'controllers' => [
'direct' => App\Http\Controllers\CustomExportController::class,
'jobs' => App\Http\Controllers\CustomExportJobController::class,
],
'services' => [
'export' => App\Services\CustomExportService::class,
'export_file' => App\Services\CustomExportFileService::class,
'permissions' => App\Services\CustomExportPermissionResolver::class,
],
],
| Feature | Detail |
|---|---|
| Lazy streaming | lazyById() cursor — peak memory stays flat at any dataset size |
| Configurable chunk size | export.chunk_size (default 500) |
| FK detection cache | Computed once per export instance |
| Schema column cache | Static cache per table, one SHOW COLUMNS per request |
| Query cache | buildQuery() result cached per instance |
| PDF settings cache | Resolver called once per ExportBuilder instance |
composer test
The test suite covers:
ExportFileService full lifecycle and delete edge casesAdvancedFilter security allowlist, relation filters, enum resolver, error recoveryHelperTrait::convertValue — all 10+ type branches and edge casesExportBuilder::buildFileName — naming consistency between direct and queued pathsstorageDisk() / storagePath()customWith, customSelect, PDF resolver variantsMIT © Hasan Hawary