Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Profiling Bundle Laravel Package

jinya/profiling-bundle

The Jinya Profiling Bundle adds profiling support to Symfony applications, helping developers collect and inspect runtime performance data to debug bottlenecks, investigate request behavior, and gain clearer insights into how their application performs during development.

View on GitHub
Deep Wiki
Context7

Getting Started

The first thing to know: this is a Symfony bundle, not a Laravel package. There is no service provider to register, no config/profiling.php to publish, no facade. If you composer require jinya/profiling-bundle in a Laravel app you will get Symfony Bundle/Extension/Configuration classes that Laravel never boots, and zero profiles will ever be written. The repo is also archived, with 0 stars and 0 dependents — do not add iy to composer.json.

So "getting started" for a Laravel developer means extracting the pattern, not the package.

Where to look first (30 minutes):

  1. Open the repo and find the class that performs the serialization — search for Yaml::dump(. That single file is the whole transferable idea: take the array of collected profiler data and write it to a file.
  2. Ignore everything else (*Bundle.php, DependencyInjection/, Resources/config/) — that is Symfony kernel plumbing with no Laravel analogue.
  3. Note the payload shape: it is essentially a flat, schema-less array of collector name → data. That is what you will redesign.

Your first use case: "Give me a file for a slow staging request — route, duration, query count, peak memory, top 10 slowest queries — that I can git diff against yesterday's baseline or attach to a ticket."

Minimum viable implementation (~150 lines, one afternoon):

// app/Profiling/ProfileDumper.php
namespace App\Profiling;

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\Yaml\Yaml;

final class ProfileDumper
{
    private array $queries = [];
    private float $startedAt;

    public function boot(): void
    {
        $this->startedAt = microtime(true);

        DB::listen(function ($q) {
            $this->queries[] = ['sql' => $q->sql, 'ms' => round($q->time, 2)];
        });
    }

    public function write(string $route): ?string
    {
        $payload = [
            'schema_version' => 1,
            'request_id'     => (string) Str::ulid(),
            'route'          => $route,
            'generated_at'   => now()->toIso8601String(),
            'duration_ms'    => round((microtime(true) - $this->startedAt) * 1000, 2),
            'peak_memory_mb' => round(memory_get_peak_usage(true) / 1048576, 2),
            'query_count'    => count($this->queries),
            'queries'        => $this->queries,
        ];

        $dir  = storage_path('app/profiler/' . now()->format('Y-m-d'));
        @mkdir($dir, 0775, true);

        $path = "{$dir}/{$payload['request_id']}.yaml";
        $tmp  = "{$path}.tmp";
        file_put_contents($tmp, Yaml::dump($payload, 6, 2));
        rename($tmp, $path); // atomic

        return $path;
    }
}
// app/Http/Middleware/DumpProfile.php
public function handle($request, Closure $next)
{
    if ($this->shouldProfile($request)) {
        app(ProfileDumper::class)->boot();
    }
    return $next($request);
}

public function terminate($request, $response): void
{
    if (! $this->shouldProfile($request)) {
        return;
    }
    app(ProfileDumper::class)->write($request->route()?->getName() ?? $request->path());
}

private function shouldProfile($request): bool
{
    return config('profiling.enabled')                    // PROFILE_DUMP=true
        && ! app()->isProduction()                        // hard guard, not config
        && ($request->header('X-Profile') === '1' || config('profiling.always'));
}
// config/profiling.php
return [
    'enabled'     => env('PROFILE_DUMP', false),
    'always'      => env('PROFILE_DUMP_ALL', false), // false = opt-in per request
    'retain_days' => 14,
];

Hit a route with curl -H 'X-Profile: 1' http://localhost:8000/orders and open the YAML. That is the whole product experience the bundle was aiming at — you now own it and it targets the framework you actually run.

Implementation Patterns

Terminable middleware is the right host. handle() registers collectors; terminate() fires after the response is flushed, so serialization cost is not in the user's critical path. Do not do the file write in handle().

Put the format behind an interface. You will want to swap YAML for JSON Lines within a month.

interface ProfileWriter { public function write(array $payload): string; }
  • YamlWriter — human-readable, good for a single ticket attachment, bad above ~50k queries.
  • JsonLinesWriter — the one you actually want for CI. One line per collector so git diff shows a single changed line, not a 4,000-line block.
  • NullWriter — for tests and PROFILE_DUMP=false.

Use a collector registry, mirroring (but simplifying) the Symfony design. Symfony's extension point is the DataCollectorInterface tagged with data_collector. The Laravel translation is a plain interface resolved from the container:

interface Collector
{
    public function key(): string;   // 'db', 'cache', 'http', 'memory'
    public function collect(): array; // must return scalars only
}
// AppServiceProvider::register()
$this->app->tag([
    \App\Profiling\Collectors\QueryCollector::class,
    \App\Profiling\Collectors\CacheCollector::class,
    \App\Profiling\Collectors\TimingCollector::class,
], 'profiler.collector');

Then the dumper iterates app()->tagged('profiler.collector'). Adding a metric becomes "write one class, register it once" — the only ergonomic win worth copying from the bundle.

Normalize to scalars at collection time, never at write time. Fluent query bindings, Eloquent models, UploadedFile, closures, and PDO statements will all blow up Yaml::dump(). Each collector is responsible for returning array<string, scalar|array>:

return [
    'sql'    => $q->sql,
    'ms'     => round($q->time, 2),
    'bindings' => array_map(
        fn ($b) => is_scalar($b) ? $b : get_debug_type($b),
        $q->bindings
    ),
];

Encode the artifact contract. Add schema_version and assert it in a contract test. This is the actual lesson from the bundle — the reason a dump is useful months later is that its shape was stable and documented:

public function test_artifact_shape_is_stable(): void
{
    $this->get('/orders', ['X-Profile' => '1'])->assertOk();

    $file = collect(glob(storage_path('app/profiler/*/*.yaml')))->sort()->last();
    $data = Yaml::parseFile($file);

    foreach (['schema_version', 'request_id', 'route', 'duration_ms', 'query_count'] as $key) {
        $this->assertArrayHasKey($key, $data);
    }
    $this->assertGreaterThanOrEqual(1, $data['schema_version']);
}

That test is what stops silent breakage from turning CI diffs into vacuous passes.

The day-to-day workflows this enables:

  1. Regression hunting. Capture the same route before and after a refactor, diff the query count and the top-5 slow queries. This is what the PM assessment calls "CI regression diffing" and it is the highest-value use.
  2. Offline handoff. cp storage/app/profiler/…/01HX….yaml /tmp && zip and attach to a ticket for a vendor or an on-call engineer without web-profiler access. That is the bundle's stated purpose, achieved without the bundle.
  3. CI artifact upload. Keep the opt-in header in a Cypress/Pest test, upload storage/app/profiler as an artifact, diff against a committed baseline file.
  4. Staging slow-path capture. Set PROFILE_DUMP=true on staging with always=true and a 1-in-50 sampler, let it run for an hour, then pull the top-10 longest duration_ms. This is when YAML starts to hurt — switch to JsonLines.

CI wiring:

- name: Profile smoke
  run: |
    php artisan serve --port=8000 &
    curl -s -H 'X-Profile: 1' http://localhost:8000/orders > /dev/null
    php artisan profiler:compare \
      --baseline=ci/baseline/orders.yaml \
      --current="$(ls -t storage/app/profiler/*/*.yaml | head -1)" \
      --max-query-regression=2 \
      --max-duration-regression-pct=25

Prune aggressively. A profiler:prune --days=14 command in routes/console.php (->daily()) is not optional — this is a self-inflicted disk-fill waiting to happen.

When not to build it: if the need is "see queries, timings, and memory for a request," Laravel Telescope already stores all of it in telescope_entries. Before writing any of the above, check whether DB::table('telescope_entries')->where('type','query')->where('batch_id', $id) in a php artisan telescope:export command gets you 90% of the way. It usually does. Telescope + a small export command is the honest baseline this pattern has to beat.

Gotchas and Tips

Framework mismatch is the first gotcha. composer require jinya/profiling-bundle in Laravel installs dead code. The Bundle subclass is never instantiated, the compiler passes never run, no kernel.event_subscriber tag is honored. It is a non-zero supply-chain cost for zero behavior. Reject in dependency review.

Archived means no compat guarantee at all. The repo has no maintainer, no issue triage, no security response, and no CI against current PHP. If you are looking at it for a Symfony service, treat it as vendored code — copy it in, keep the MIT notice, and accept that framework upgrades (Symfony profiler's internal data shape changed across 5.x/6.x/7.x) will break it silently. For Symfony, prefer native profiler-pack with the --profile CLI flag, which already gives you offline profile capture without a third-party bundle.

Redact at the collector, not afterward. Profiler payloads routinely contain Authorization headers, cookies, session IDs, request bodies, and email addresses. Artifacts uploaded as CI artifacts are a data-exfiltration vector. Use an allowlist, not a denylist:

'redact_keys' => ['authorization', 'cookie', 'set-cookie', 'password', 'token', 'secret'],

and drop request bodies entirely by default. There is no "we'll redact it in the CI step" — by then it is in the file.

Guard production with code, not config. config('profiling.enabled') can be set wrong. Put ! app()->isProduction() directly in the middleware's shouldProfile() so a misconfigured env var cannot dump in prod.

Do not dump every request. Serialization is not free — full query logs on a request with 400 queries can add tens of milliseconds and measurable memory. Use opt-in headers on local, sampling (1-in-N, or error-only) on staging.

Atomic writes and unique names. Two concurrent requests hitting the same timestamp-based filename produces a truncated YAML that parses as null and then makes every downstream diff fail confusingly. Use ULID filenames and write-temp-then-rename().

YAML scales badly. Verbose, slow to parse, and large payloads (full query logs, session snapshots) blow up size. Git diffs of YAML are readable only at small scale. Use YAML for one-off human handoff, JsonLines for anything automated.

Watch for the vacuous-pass failure mode. A throwing dumper caught in try/catch and logged at debug level is correct behavior (never break the request), but it also hides breakage — CI will happily diff two empty artifacts and report success. Alert on dumper error rate, and add the "artifact contains ≥N keys" assertion from the pattern above.

Pin symfony/yaml explicitly. Laravel already pulls Symfony components transitively; a loose constraint can drift and break parsing in ways that look like your code. composer require symfony/yaml:^7.0 in the internal package keeps it honest.

Extension points worth having from day one (these are the ones the bundle got right, translated):

  • Collector interface + container tag → add metrics without touching the dumper.
  • ProfileWriter interface → swap YAML/JSON/NDJSON/DB without touching collectors.
  • schema_version in the payload → consumers can migrate.
  • Config retain_days + a prune command → disk safety.

Debugging tip: make the dumper's destination path come back from write() and log it at info when opt-in is used. When a developer says "I profiled it and there's no file," the answer is almost always a permission issue on storage/app/profiler or the terminate() never firing (which happens when the middleware is registered as a route middleware but the request is served by Octane without the terminable hooks wired — check octane.php if you run RoadRunner or Swoole).

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect