DEV Community

Daniel, Petrica Andrei-Daniel
Daniel, Petrica Andrei-Daniel

Posted on • Originally published at danielpetrica.com on

My LaraPlugins Performance Audit: 12 Fixes That Saved my Stressed server

My LaraPlugins Performance Audit: 12 Fixes That Saved my Stressed server

My 8 GB Hetzner VPS was sitting at 90%+ memory usage, all day, every day. New Relic alerts were pinging me constantly. The noise was so bad I started tuning them out, which is exactly when real problems slip through.

LaraPlugins.io indexes over 76,000 Laravel packages. It runs health checks, syncs GitHub data, ingests Packagist versions, and serves a public directory. Behind the scenes, Horizon workers chew through jobs 24/7. A few weeks ago, the server was barely keeping up.

I spent a week digging into the bottlenecks. Here are the 12 fixes that made a real difference, with the actual numbers.

The Starting Point

Twelve Horizon supervisors, each with unlimited job processing. Workers grabbing 500 MB each. A monolithic maintenance supervisor that ran for two hours at a time. And during the weekly plugin health recalculation, a single job type was triggering 50,000 Cloudflare cache purge jobs in one cycle.

The server had 8 GB of RAM. It was not enough.

Fix 1: Split the Maintenance Monolith

The maintenance supervisor was one worker processing everything: GitHub syncs, health calculations, analytics, and background tasks. It ran with a 7,200-second timeout. If anything stalled, everything stalled.

I split it into dedicated supervisors: github-sync (2 workers, 300s timeout, 400 MB) and health (2 workers, 600s timeout, 500 MB). Each does one thing. If GitHub's API rate-limits the sync worker, the health worker keeps running.

The old analytics supervisor became background, merged with low-priority tasks, and dropped from 250 MB to 128 MB. Generic names like "low" and "analytics" became names that describe what the queue actually does. Makes debugging faster when something breaks at 3 AM.

Fix 2: Memory Caps Everywhere

Every Horizon worker had maxJobs: 0, meaning unlimited. A worker could process jobs forever, slowly leaking memory until the OOM killer stepped in.

I set maxJobs: 100 across all supervisors. After 100 jobs, the worker restarts. Memory gets released back to the OS. The defaults also dropped: 500 MB became 256 MB on the default queue, 384 MB on packagist-high, and 128 MB on background. Combined, the memory ceiling dropped by roughly 2 GB across all workers.

The failed job retention window went from 10,080 minutes (7 days) to 7,080 (5 days). Horizon's queue trim snapshot interval jumped from 12 to 2,016 minutes. These are small config changes that add up to less Redis memory churn.

Fix 3: The 50,000-Job Problem

Every time a plugin's data was updated, a job dispatched to purge its Cloudflare cache. During the weekly health recalculation, with all 56,000 plugins being refreshed, over 50,000 InvalidatePluginCloudflareCache jobs hit the queue.

That is 50,000 individual Redis pushes, 50,000 worker pickups, 50,000 Cloudflare API calls spaced across the queue. The queue backed up. Workers spent cycles dispatching and picking up purge jobs instead of doing actual health calculations.

The fix: a Redis SADD collects purged paths into a set. A single FlushPendingCloudflarePurgesJob pops 30 URLs at a time, sends them to Cloudflare's bulk purge endpoint, and self-reschedules if more remain. Max 10 batches per cycle, or 300 URLs per flush. Instead of 50,000 jobs, it is one job calling itself. The Redis set acts as a natural deduplicator as well: if the same plugin path is marked for purge twice, SADD only stores it once.

Fix 4: The Column Nobody Reads

The plugins table has a packagist_metadata column. It is a JSON blob containing every version's metadata: requirements, dependencies, descriptions. It is large. And it was loaded on every single query to the Plugin model, even though sync jobs never read it. They only write it.

I added a WithoutPackagistMetadataScope global scope to the Plugin model. Every query now excludes that column by default. Jobs that need the metadata load it explicitly with withoutGlobalScope(). The query payload for list operations dropped significantly. MySQL spent less time shuffling JSON blobs across the wire.

Fix 5: Indexes That Should Have Been There

Three missing indexes: plugin_versions support version columns (used in every compatibility check), failed_jobs.queue (Horizon's dashboard queries it constantly), and the vendor detail page query path. These were not exotic compound indexes. They were obvious single-column indexes that I simply had not added when the tables were created.

The vendor detail page also got a column selection pass: instead of SELECT *, it now selects only the columns it actually renders. Small change, but the page went from noticeably slow to instant.

Fix 6: Cache Longer

Aggregate queries (plugin counts by tag, health score distributions, PHP version stats) were cached for too short a window. I bumped the TTLs to 6 hours and 24 hours depending on the query. These numbers do not change between deploys. There is no reason to recalculate them every 30 minutes.

Fix 7: Queue MCP analytics Off the Critical Path

LaraPlugins exposes an MCP server for the plugin search tool. Every request was logging anonymized analytics events synchronously before returning a response. A event write meant a slow MCP response. Moved the logging to a queued job on the background queue. MCP responses stayed fast regardless of log throughput.

Fix 8: Health Checks That Checked Nothing

Several health check jobs were running at frequencies that did not match the data freshness they protected. Some were redundant checks that another job already performed. Trimmed the schedule to match actual refresh needs and removed the duplicates.

Fix 9: MySQL Tuning

Increased the sort_buffer_size in the Docker Compose MySQL config. LaraPlugins runs sorting-heavy queries on plugin lists (by downloads, by stars, by health score). The default sort buffer was too small for the dataset size, causing disk-based sorts that slowed down listing pages.

Fix 10: Horizon Retention Trimming

Horizon stores job history in Redis. The completed job retention was at 60 minutes, the queue trim snapshot interval at 12 minutes. These are fine for a small app. For LaraPlugins, with thousands of jobs per hour, Redis memory was bleeding into job history storage. Bumped the queue trim to 2,016 minutes and reduced failed job retention to 5 days. Redis memory stabilized.

Fix 11: Rate Limit Recovery

GitHub's API rate-limits aggressively. When the sync worker hit the limit, it would crash and Horizon would retry it, sometimes before the rate limit window reset. Added explicit rate limit detection with proper backoff: when GitHub returns a 429 or a retry-after header, the job releases itself back to the queue with a delay matching the reset window. No more spinning wheels against a rate limit wall.

Fix 12: Nightwatch Silencing

Laravel Nightwatch was ingesting every queue job, every request, every log into its agent. For a job-heavy app like LaraPlugins, that meant an enormous volume of observability data, most of it noise. Reduced the event ingestion to only what I actually look at. The agent container stopped being a resource drain.

The Results

The 8 GB VPS dropped from 90%+ memory usage with constant alerts to a stable 65 to 80% range. New Relic went quiet. Response times stayed flat at under 100 ms average. No regressions.

The Horizon queue shrank from 12 supervisors doing vague things to 9 supervisors doing specific things. Memory leaks from unlimited job processing stopped. The 50,000-job Cloudflare purge spike became a single self-rescheduling job.

And I can sleep without my phone buzzing about a server that is "probably fine."

What I Would Do Differently

Most of these fixes were not clever. They were cleanup. Indexes I forgot to add. A column nobody read being loaded on every query. A cache TTL that was too short for no good reason. Queue names that made debugging harder.

If I were starting LaraPlugins again, I would add maxJobs to every Horizon supervisor from day one. I would default to global scopes for large JSON columns on read-heavy tables. And I would batch external API calls by default instead of dispatching one job per call. These are not architecture decisions. They are habits.

The biggest lesson: when your monitoring goes noisy, you have exactly two choices. Fix the root cause, or tune the alerts. Every day you tune the alerts without fixing the cause, you are training yourself to ignore your own dashboard.

Top comments (0)