The 7 Laravel Packages I Install on Every Single Project — And the 3 I Deleted After Wasting a Week on Them

Spatie Permission, Laravel Auditing, Sentry, Wire Elements, Laravel Data, and two others that earn their place every time — plus the three popular packages that looked promising, created more problems than they solved, and got removed before launch.


Package recommendations usually come from one of two places: people who installed the package in a tutorial project and thought it was good, or people who’ve been using it in production for two years and know exactly where it breaks. This post is the second kind. Every package in the “install every time” list has been in production for at least three projects and at least eighteen months. Every package in the “deleted it” list has cost real hours — not hypothetical concerns about coupling or philosophy, but actual debugging time that ended with composer remove.

The list is opinionated because it has to be. A neutral package recommendation is useless.


The 7 That Earn Their Place

1. spatie/laravel-permission

What it is: Role and permission management backed by the database, with middleware, Blade directives, and Eloquent integration.

Why it’s on every project: Authorization is a solved problem. The alternatives are writing it yourself — which means weeks of work, inconsistent implementation, and eventual bugs at the edges — or using a package that’s been in production in tens of thousands of Laravel applications for nearly a decade. Spatie Permission wins because it makes no wrong assumptions: roles can have permissions, users can have permissions directly, permissions are guarded by auth guards, and all of it is cacheable.

// Installation
composer require spatie/laravel-permission
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migrate

// Assign roles and permissions
$user->assignRole('editor');
$user->givePermissionTo('edit articles');

// Check in a policy or controller
$this->authorize('edit articles');

// Check in Blade
@can('edit articles')
    <a href="{{ route('articles.edit', $article) }}">Edit</a>
@endcan

// Check via middleware
Route::middleware('permission:publish articles')->group(function () {
    Route::post('/articles/{article}/publish', [ArticleController::class, 'publish']);
});

The one configuration detail most tutorials skip: enable caching in production.

// config/permission.php
'cache' => [
    'expiration_time'  => \DateInterval::createFromDateString('24 hours'),
    'key'              => 'spatie.permission.cache',
    'store'            => 'default',
],

Without caching, every permission check hits the database. In an application that checks permissions on every authenticated request, that’s a significant overhead. Enable it on day one. Clear it when roles or permissions change.

The limitation to know upfront: Spatie Permission isn’t designed for attribute-based access control (ABAC) — “user can edit this specific article because they’re the author.” For that you need Policies, and Policies compose cleanly with Spatie Permission: use $user->hasPermissionTo('edit articles') inside the Policy to check the role-based permission before applying the attribute-based logic.


2. owen-it/laravel-auditing

What it is: Automatic Eloquent model change tracking — records who changed what, when, from what value to what value, and from which IP address.

Why it’s on every project: The question “who changed this?” comes up on every production application, usually at the worst possible time — during a dispute, an investigation, or a support escalation. Without auditing, the answer is “we don’t know.” With auditing, the answer is a database query.

// Add the trait to any model that needs auditing
class Order extends Model
{
    use \OwenIt\Auditing\Auditable;

    // Attributes to exclude from audit records
    protected array $auditExclude = [
        'updated_at',
    ];
}

That’s the entire integration. Every create, update, and delete on the Order model is now recorded in the audits table with the old values, the new values, the user who made the change, the IP address, and the timestamp.

// Query audit history for a specific record
$order = Order::find(1);

foreach ($order->audits as $audit) {
    echo $audit->user->name;
    echo $audit->event;           // created, updated, deleted
    echo $audit->getModified();   // ['status' => ['old' => 'pending', 'new' => 'shipped']]
}

// Query across all orders changed by a specific user
Audit::where('user_id', $userId)
     ->where('auditable_type', Order::class)
     ->latest()
     ->get();

The configuration detail that matters: exclude sensitive fields and limit audit retention.

// config/audit.php
'threshold'  => 100,   // keep only 100 audit records per model instance
'console'    => false, // don't audit changes made from Artisan commands

Without threshold, the audits table grows unboundedly. A model that’s updated frequently — like an order that moves through statuses — generates a row per update indefinitely. Set threshold to a sensible number based on your retention requirements.


3. sentry/sentry-laravel

What it is: Error tracking, performance monitoring, and release tracking integrated with Laravel’s exception handler.

Why it’s on every project: Log::error() tells you an error happened. Sentry tells you the error happened 47 times, on these 12 users, starting after the 3:15pm deployment, with this stack trace and these breadcrumbs showing the 8 steps that preceded it. The gap between knowing an error exists and knowing what caused it for which users is the gap Sentry closes.

composer require sentry/sentry-laravel
php artisan sentry:publish --dsn=YOUR_DSN
// config/sentry.php — the settings that matter most in production
return [
    'dsn'                 => env('SENTRY_LARAVEL_DSN'),
    'traces_sample_rate'  => env('SENTRY_TRACES_SAMPLE_RATE', 0.1), // 10% of requests
    'profiles_sample_rate' => 0.0,  // profiling adds overhead — enable only when needed
    'send_default_pii'    => false,  // GDPR — don't send user PII by default
    'environment'         => app()->environment(),
];

traces_sample_rate of 0.1 means Sentry traces 10% of requests for performance monitoring. At 0% it’s error tracking only. At 100% it captures every request for tracing — useful in staging, too much overhead in high-traffic production.

The integration that makes it genuinely useful:

// Set user context so Sentry shows which users hit which errors
// app/Http/Middleware/IdentifyUserForSentry.php
class IdentifyUserForSentry
{
    public function handle(Request $request, Closure $next): Response
    {
        if ($request->user()) {
            \Sentry\configureScope(function (\Sentry\State\Scope $scope) use ($request) {
                $scope->setUser([
                    'id'    => $request->user()->id,
                    'email' => $request->user()->email,
                    // Don't include name or other PII if GDPR applies
                ]);
            });
        }
        return $next($request);
    }
}

Without user context, a spike in errors is a spike in errors. With user context, it’s “18 users on the enterprise plan hit this error after logging in with SSO.” The second version has an actionable diagnosis.


4. wire-elements/modal

What it is: A Livewire v3 modal system that opens modals from anywhere in your application using events.

Why it’s on every project: Every application has modals. Building a modal system that works cleanly with Livewire’s component model — that can be triggered from sibling components, that handles stacking, that doesn’t require passing state through component trees — takes a week and still has edge cases. Wire Elements Modal solves this correctly in one composer require.

composer require wire-elements/modal
php artisan vendor:publish --tag=livewire-modal
// Any Livewire component can become a modal
class EditProjectModal extends Component
{
    use \LivewireUI\Modal\Traits\WithModal;

    public Project $project;

    public function mount(Project $project): void
    {
        $this->project = $project;
    }

    public function save(): void
    {
        $this->project->update($this->validate());
        $this->closeModal();
        $this->dispatch('project-updated');
    }

    public function render(): View
    {
        return view('livewire.edit-project-modal');
    }
}
{{-- Open the modal from any Blade or Livewire template --}}
<button wire:click="$dispatch('openModal', { component: 'edit-project-modal', arguments: { project: {{ $project->id }} } })">
    Edit Project
</button>

{{-- Place this once in your layout --}}
<livewire:modal />

The openModal event works from any Livewire component, from Alpine.js, or from JavaScript. The modal component receives the arguments and mounts normally. Closing calls $this->closeModal() or dispatches closeModal. Stacked modals (a modal opening another modal) work without additional configuration.


5. spatie/laravel-data

What it is: Typed Data Transfer Objects for Laravel — a way to define strongly typed objects that can be created from requests, validated, transformed to arrays or JSON, and cast to and from Eloquent model attributes.

Why it’s on every project: The alternative to Laravel Data is arrays everywhere. An array that represents a product creation request has no type information, no autocompletion, no validation logic attached to the shape itself. A CreateProductData object has all of those. The IDE knows what fields exist. TypeScript-style refactoring works. Validation rules live with the data shape.

// Define the data object
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Attributes\Validation\Rule;

class CreateProductData extends Data
{
    public function __construct(
        #[Rule('required|string|min:3|max:255')]
        public readonly string $name,

        #[Rule('required|integer|min:0')]
        public readonly int $price,

        #[Rule('required|in:draft,published,archived')]
        public readonly string $status,

        #[Rule('nullable|string')]
        public readonly ?string $description,
    ) {}
}

// Create from a request — validates automatically
class ProductController extends Controller
{
    public function store(Request $request): RedirectResponse
    {
        $data = CreateProductData::from($request);
        // $data is validated, typed, and ready to use
        // $request->validate() is handled by the Data class

        Product::create($data->toArray());
        return redirect()->route('products.index');
    }
}

// Create from an Eloquent model
$data = CreateProductData::from(Product::find(1));

// Transform to array for API responses
return response()->json($data->toArray());

The from() method is what makes Laravel Data genuinely useful: it creates the data object from a request (validating in the process), from an array, from a model, or from another data object. One class handles validation, transformation, and serialization.


6. spatie/laravel-query-builder

What it is: A package that translates HTTP query parameters into Eloquent query builder calls — filtering, sorting, and including relationships based on request parameters.

Why it’s on every project: Every API endpoint that returns a list of resources needs filtering and sorting. Writing if ($request->has('status')) { $query->where('status', $request->status); } for every filterable field on every endpoint is boilerplate that grows without bound. Laravel Query Builder handles it in a single, declarative configuration per endpoint.

use Spatie\QueryBuilder\QueryBuilder;
use Spatie\QueryBuilder\AllowedFilter;

class ProjectController extends Controller
{
    public function index(Request $request): JsonResponse
    {
        $projects = QueryBuilder::for(Project::class)
            ->allowedFilters([
                AllowedFilter::exact('status'),
                AllowedFilter::partial('name'),      // LIKE %name%
                AllowedFilter::exact('owner_id'),
                AllowedFilter::scope('created_after', 'createdAfter'), // custom scope
            ])
            ->allowedSorts(['name', 'created_at', 'updated_at'])
            ->allowedIncludes(['owner', 'tags', 'tasks'])
            ->defaultSort('-created_at')
            ->paginate(15);

        return ProjectResource::collection($projects)->response();
    }
}

The client now controls filtering and sorting without any additional controller code:

GET /api/projects?filter[status]=active&sort=-created_at&include=owner
GET /api/projects?filter[name]=Acme&filter[owner_id]=12&sort=name
GET /api/projects?page=2&filter[status]=archived&include=tags,tasks

Attempting to filter by a field not in allowedFilters throws a InvalidFilterQuery exception — it doesn’t silently ignore the parameter or expose an SQL injection surface. The allowlist is the security model.


7. barryvdh/laravel-ide-helper

What it is: Generates PHPDoc blocks that tell your IDE about Eloquent model attributes, relationships, and magic methods — so autocompletion and static analysis work on models.

Why it’s on every project: Without IDE helper, your IDE doesn’t know that $order->customer is a Customer, that $order->total is an integer, or that $order->complete() is a method. It treats every model attribute and relationship as mixed. Refactoring is blind. With IDE helper, the IDE knows everything about your models, facades, and container bindings.

composer require --dev barryvdh/laravel-ide-helper
php artisan ide-helper:generate    # Facade docblocks
php artisan ide-helper:models -W   # Model attribute docblocks (writes to model files)
php artisan ide-helper:meta        # Container binding metadata for PhpStorm

Add to composer.json so it regenerates after migrations:

{
    "scripts": {
        "post-update-cmd": [
            "Illuminate\\Foundation\\ComposerScripts::postUpdate",
            "@php artisan ide-helper:generate",
            "@php artisan ide-helper:models -W"
        ]
    }
}

This is a dev-only package — --dev in the require. It never ships to production. The generated docblocks it produces do ship, embedded in the model files, but the package itself is not needed at runtime.


The 3 I Deleted

1. treblle/laravel-treblle

What promised it would be: API observability — automatic logging of every request and response payload, with a dashboard showing API usage patterns, error rates, and client behaviour.

What it actually cost: Every API request sent a copy of the full request and response — including request bodies — to Treblle’s external service. On a GDPR-sensitive application with user data in request payloads, this required a detailed data processing assessment that took longer than the feature itself. The free tier had enough request volume limits that production traffic regularly hit them. The payload scrubbing configuration to exclude sensitive fields was exhaustive and fragile — miss a field, ship PII to a third party.

What I replaced it with: Sentry for errors, a custom DB::listen() log for slow queries, and Telescope in staging. Same observability, no third-party payload transmission. The specific value Treblle offered — seeing request/response pairs in a timeline — wasn’t worth the data governance overhead.

When it might be right: Internal tooling APIs with no user PII, where request/response visibility matters more than data governance compliance.


2. laravel/scout with meilisearch/meilisearch-php

What promised it would be: Full-text search with relevance ranking, typo tolerance, and faceted filtering. The combination of Scout’s Eloquent integration and Meilisearch’s performance looked like a clean replacement for basic LIKE queries.

What it actually cost: The indexing pipeline. Every model change triggers a sync job to Meilisearch. In an application with high write volume — 50,000 order updates per day across a large customer base — the sync queue became a constant source of lag. The index was always slightly behind. Searches that should return a just-created order returned nothing. Searches for an updated order returned the pre-update version.

More critically: Meilisearch doesn’t support complex relational queries. “Show me projects where the owner has role admin and the project was created in the last 30 days” is two database joins. In Meilisearch, it’s a compound filter on denormalized data that you have to keep synchronized.

The specific configuration that broke it:

// The Scout sync job fires on every model update
// With 50,000 updates/day, this adds 50,000 jobs to the sync queue
class Order extends Model
{
    use Searchable;
    // This fires ScoutModelObserver on every save() — including minor status updates
}

The fix — only sync on specific fields changing — worked technically but required a custom observer that reimplemented half of Scout’s logic.

What I replaced it with: PostgreSQL full-text search via tsvector for simple keyword matching, and spatie/laravel-query-builder with database filters for everything more complex. No external service, no sync queue, no eventual consistency issues. For the use cases that actually needed relevance ranking, the pg_trgm extension provided good-enough fuzzy matching at the database level.

When it might be right: Read-heavy applications with low write volume where the data shape is mostly flat. E-commerce product catalogs with infrequent updates and complex faceting requirements. Definitely not high-write transactional applications.


3. nWidart/laravel-modules

What promised it would be: A first-class module system for Laravel — separate folders, separate route files, separate service providers per module. The pitch: organize a large application into self-contained modules rather than the flat folder structure that comes with Laravel by default.

What it actually cost: A complete reimplementation of Laravel’s auto-discovery, routing, and provider loading that diverged from core Laravel in subtle ways. Third-party packages that expected the standard app/ and routes/ paths broke or required configuration. Filament, Horizon, and Pulse all needed custom configuration to work within the module structure. Artisan generators produced files in the wrong locations and needed aliases or config overrides for every generator command.

The deeper problem: the “module” concept the package implements is a folder structure. It doesn’t enforce module boundaries, doesn’t prevent cross-module coupling, and doesn’t provide any runtime isolation. It gave the appearance of modular architecture with all the overhead of managing a non-standard Laravel structure, but none of the actual benefits of a module system.

What I replaced it with: The feature-folder structure within the standard Laravel app directory. Features in app/Features/Projects/, app/Features/Billing/, app/Features/Tasks/. Service providers per feature registered from the main AppServiceProvider. Routes in routes/ organized by feature file. All of the organizational benefit, none of the framework divergence, and every third-party package worked immediately.

When it might be right: A very large application (100+ developers, multiple teams, genuine team-level isolation requirements) where the overhead of managing a non-standard structure is justified by the organizational need. At that scale, a custom internal module system — one that the team owns and understands completely — is probably better than nWidart anyway.


The Pattern Behind the Decisions

The packages that earn their place share three characteristics: they do one thing and do it at the framework level (Spatie Permission hooks into the Gate, Laravel Auditing hooks into Eloquent observers, IDE Helper hooks into Artisan), their failure mode is loud rather than silent (permission denied is an exception, not a wrong value), and the day you remove them is more work than the day you installed them — a sign that the abstraction was genuine, not superficial.

The packages that got deleted share a different pattern: they introduced an external dependency with synchronization requirements (Treblle’s payload transmission, Scout’s index sync), or they imposed a non-standard structure that diverged from the framework’s expected conventions (nWidart’s module system), or their complexity cost exceeded their value when the edge cases became production issues.

The composer remove moment is the data point most package recommendations don’t include. The packages you install and never think about again are the good ones. The packages you install and gradually wrap in workarounds until removing them becomes a weekend project — those are the ones worth warning about.

Leave a Reply

Your email address will not be published. Required fields are marked *