yajra/laravel-datatables-fractal
Laravel DataTables plugin that transforms server-side responses using Fractal transformers. Requires PHP 8.2+ and Laravel 12.x. Install via Composer and register the optional service provider to get started.
Prerequisites — this plugin is glue, not a framework. It does nothing unless you already have server-side yajra/laravel-datatables in place, plus league/fractal. Confirm PHP >= 8.2, Laravel 12.x/13.x, and that the package major matches your Laravel major (^13.0 for Laravel 13, ^12.0 for Laravel 12). The version is not independent — it tracks the framework, so treat the install as part of the framework's version line.
composer require yajra/laravel-datatables-fractal:^12.0
# Optional, but do it once and commit the file
php artisan vendor:publish --tag=datatables-fractal
The service provider is auto-discovered. The published config is two-ish keys in practice: serializer (which Fractal serializer wraps the rows) and includes (the request key used to parse Fractal includes — defaults to include).
Where to look first — vendor/yajra/laravel-datatables-fractal/src is tiny (the transformer bridge is well under a few hundred lines) and config/datatables-fractal.php. Documentation is thin; reading the source is faster than searching. Then look at your existing app/Transformers directory — if you have one, you already have the payoff.
First use case — a list screen whose entity is already serialized by a Fractal transformer for an API consumer. The whole point of the package is that the admin table and the API return the same row shape from the same class:
use App\Models\User;
use App\Transformers\UserTransformer;
use Yajra\DataTables\DataTables;
public function index(Request $request)
{
return DataTables::eloquent(User::query()->with('roles'))
->setTransformer(new UserTransformer)
->toJson();
}
class UserTransformer extends TransformerAbstract
{
protected array $availableIncludes = ['roles'];
public function transform(User $user): array
{
return [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'status' => $user->status,
'created_at' => $user->created_at?->toIso8601String(),
];
}
public function includeRoles(User $user)
{
return $this->collection($user->roles, new RoleTransformer);
}
}
That is the entire day-to-day surface: setTransformer() on a DataTables query, a TransformerAbstract subclass, and a frontend whose columns[].data keys match the transformer's array keys.
One transformer, two consumers. The pattern that justifies adoption is a transformer that feeds both GET /api/users and GET /admin/users-data. The DataTables endpoint inherits every field, cast, and date format the API already guarantees. The moment you fork a transformer "just for the table," the reuse thesis has failed — fix the transformer instead.
Includes are opt-in and must be eager-loaded. Requesting ?include=roles makes Fractal resolve the relation per row; if the query did not ->with('roles'), you have manufactured an N+1 on the busiest screens. Standardize on declaring allowed includes in $availableIncludes and pre-loading them in the controller, not lazily inside the transformer.
Computed fields go in withCount/select aliases, not PHP loops. Derive aggregates in the query (->withCount('orders')) and expose them in transform(). Anything computed by iterating in PHP kills performance on large pagination.
Keep column logic out of the DataTables chain. When a transformer is set, the transformer's return value is what the row becomes. Prefer emitting DT_RowIndex, formatted dates, status badges, and nested relation keys directly from the transformer rather than mixing in addColumn/editColumn/rawColumns. Pick one serialization path per table and stay on it.
Row numbering. addIndexColumn() is unreliable in transformer mode. Carry the offset from the request ($request->input('start', 0) + 1) or emit an index from a per-request counter in the transformer; otherwise your # column resets to 1 on every page.
Serializer choice is a frontend contract. The default flat-array serializer produces rows that DataTables maps directly (data.name). If you switch datatables-fractal.serializer to JsonApiSerializer, rows become JSON:API resource objects and every column definition must move to data.attributes.name. Decide once, commit the config, and reflect it in the frontend's column map — not per-screen.
Search and sort still hit the database. Transformed fields are display-only. If a column must be filterable/orderable but doesn't exist as a DB column, implement filterColumn() / orderColumn() callbacks or add a selectRaw alias so the query can sort on it.
Test the envelope, not the UI. A single feature test asserting assertJsonStructure(['data' => [['id','name','status']], 'recordsTotal', 'recordsFiltered']) catches the breakages that matter (serializer change, transformer rename, missing include). Do this from day one — there is no ecosystem of dependents acting as your compatibility signal.
Migrate on touch. Convert legacy hand-written endpoints only when a screen is already being modified, and keep the old endpoint alive until the new one has shipped for a release cycle.
transform() output; plan filterColumn/orderColumn for every derived column.DataTables::eloquent() passes Eloquent models; DataTables::of() (query builder/collection) passes arrays or stdClass. A transform(User $user) signature silently breaks under of().input, not get — that changed in 12.0.1. Includes sent in a POST/JSON body now work; if you're on 12.0.0 or pinned an older line, they won't. Note this in your upgrade checklist, and don't be surprised when a ?include= on a GET behaves differently from a body payload.includes config key if it collides. If your app already uses ?include= for something else, remap it in the published config rather than fighting it in each controller.rawColumns for HTML. Badges, action buttons, and inline links get clobbered or duplicated. Emit those values from the transformer and let the frontend render, or skip the transformer on that table entirely.created_at->toIso8601String() in the transformer beats a frontend moment.js call and keeps the API contract identical.storage/logs/laravel.log first; the stack trace is there, not in the XHR response.--force on publish overwrites your committed config. Publish once, then review config diffs like any other source file.TransformerAbstract's defaultIncludes/availableIncludes. If a requirement needs more than those, it probably belongs in your application code or in Laravel API Resources — don't stack both serialization standards on one screen.How can I help you explore Laravel packages today?