kbeaud11 / laravel-cloud-tracker
Track usage-based infrastructure costs per model in Laravel Cloud environments.
Requires
- php: ^8.2
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- mockery/mockery: ^1.6
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.0|^12.0
This package is auto-updated.
Last update: 2026-03-25 18:48:04 UTC
README
Track usage-based infrastructure costs per model in Laravel Cloud environments. Built for observability — not billing.
Laravel Cloud Tracker gives you per-entity, per-feature cost visibility by wrapping expensive operations in a fluent API that measures execution time, calculates estimated cost across multiple infrastructure dimensions, and stores both granular events and aggregated monthly rollups.
Why?
Laravel Cloud bills by usage: compute time, database CPU, cache operations, bandwidth, and more. When you run a multi-tenant SaaS, you need to understand which tenants consume which resources and how much it costs.
This package answers questions like:
- "How much compute does Organization X consume monthly?"
- "What's the most expensive feature across all tenants?"
- "Are our enterprise clients actually covering their infrastructure costs?"
It does not enforce billing, integrate with Stripe, or render UI. It's the observability layer that makes those things possible.
Requirements
- PHP 8.2+
- Laravel 11 or 12
- MySQL, PostgreSQL, or SQLite
Installation
composer require kbeaud11/laravel-cloud-tracker
Publish Configuration
php artisan vendor:publish --tag=cloud-tracker-config
Publish & Run Migrations
php artisan vendor:publish --tag=cloud-tracker-migrations php artisan migrate
This creates three tables:
| Table | Purpose |
|---|---|
model_tracking_policies |
Per-model tracking configuration (mode, feature lists, multiplier) |
model_usage_events |
Granular event log for every tracked operation |
model_usage_rollups |
Monthly aggregates per model + feature for fast querying |
Configuration
After publishing, the config lives at config/cloud-tracker.php.
Master Switch & Environments
'enabled' => env('CLOUD_TRACKER_ENABLED', true), 'environments' => ['production', 'staging'],
Tracking is disabled by default in local development. The callback always executes — only timing, cost calculation, and DB writes are skipped.
Laravel Cloud Plan
'plan' => env('CLOUD_TRACKER_PLAN', 'growth'),
Stored for reference and future quota features. Does not affect per-unit rates (those are determined by instance selection).
Supported values: starter, growth, business, enterprise.
Cost Dimensions
Every billable Laravel Cloud resource is represented as a cost dimension. Rates are pre-populated from Laravel Cloud's published pricing (US East region).
Compute (Time-Based)
Select your instance size via environment variable:
CLOUD_TRACKER_COMPUTE_INSTANCE=pro-2c-4g
Available instances:
| Instance | Monthly | Per Second |
|---|---|---|
flex-1c-256m |
$4/mo | $0.00000165/s |
flex-2c-512m |
$8/mo | $0.00000331/s |
flex-4c-2g |
$16/mo | $0.00000661/s |
pro-1c-1g |
$20/mo | $0.00000827/s |
pro-2c-4g |
$40/mo | $0.00001650/s |
pro-4c-8g |
$80/mo | $0.00003310/s |
Queue Workers (Time-Based)
Queue workers use the same instance pricing as compute but are configured independently:
CLOUD_TRACKER_QUEUE_INSTANCE=flex-2c-512m
Serverless Postgres (Time-Based)
Billed at $0.106/hour per vCPU:
'postgres' => [ 'unit' => 'time', 'per_second' => 0.00002944, ],
Cache / Valkey (Flat Monthly)
Flat monthly rate amortized over estimated operations:
CLOUD_TRACKER_CACHE_TIER=1g
| Tier | Monthly |
|---|---|
250m |
$6/mo |
1g |
$20/mo |
2g |
$40/mo |
5g |
$80/mo |
10g |
$140/mo |
25g |
$200/mo |
50g |
$272/mo |
Adjust estimated_operations_per_month to match your actual usage for more accurate per-operation cost.
WebSockets / Reverb (Flat Monthly)
CLOUD_TRACKER_WEBSOCKET_MONTHLY=5.00
Amortized over estimated_messages_per_month (default: 1,000,000).
Bandwidth (Count-Based)
'bandwidth' => [ 'unit' => 'count', 'per_gb' => 0.10, ],
Object Storage (Count-Based)
'storage' => [ 'unit' => 'count', 'per_1k_operations' => 0.0005, 'per_gb_month' => 0.02, ],
Event Logging
'log_events' => true,
Set to false to skip writing to model_usage_events and only maintain rollups. Reduces write volume in high-throughput scenarios.
Default Dimension
'default_dimension' => 'compute',
Applied when no dimension is explicitly chained on a track() call.
Usage
1. Add the Trait to Your Billable Model
use LaravelCloudTracker\Traits\TracksCloudCost; class Organization extends Model { use TracksCloudCost; }
This provides three relationships:
$org->trackingPolicy; // MorphOne — the model's tracking configuration $org->usageEvents; // MorphMany — all granular usage events $org->usageRollups; // MorphMany — monthly rollup aggregates
2. Track an Operation
use LaravelCloudTracker\Facades\CloudCost; $result = CloudCost::for($organization) ->feature('smart_segment_rebuild') ->track(function () use ($segment) { return $segment->rebuild(); });
The callback's return value is always passed through. Timing, cost calculation, event logging, and rollup upsert happen transparently.
3. Chain Multiple Dimensions
When an operation spans multiple infrastructure resources:
CloudCost::for($organization) ->feature('live_dashboard_update') ->dimension('compute') ->dimension('postgres') ->dimension('cache', operations: 25) ->dimension('websocket', quantity: 3) ->track(function () use ($dashboard) { $data = $dashboard->aggregateMetrics(); // compute + postgres Cache::put("dashboard:{$dashboard->id}", $data); // cache broadcast(new DashboardUpdated($data)); // websocket return $data; });
Each dimension calculates cost independently based on its unit type, then all are summed.
For time-based dimensions (compute, postgres, queue), cost is derived from execution time automatically.
For count-based and flat-monthly dimensions (cache, websocket, bandwidth, storage), pass a quantity:
->dimension('cache', quantity: 50) // 50 cache operations ->dimension('websocket', quantity: 3) // 3 messages broadcast ->dimension('bandwidth', quantity: 0.5) // 0.5 GB transferred
4. Force Tracking (Bypass Policy)
For admin or internal operations that should always be tracked regardless of the model's policy:
CloudCost::for($organization) ->feature('admin_data_export') ->force() ->track(fn () => $exporter->run());
force() bypasses the tracking policy but still respects environment restrictions (local stays disabled).
5. Attach Metadata
Store arbitrary context with the event for debugging or reporting:
CloudCost::for($organization) ->feature('csv_import') ->withMetadata([ 'job_id' => $this->job->getJobId(), 'rows' => $rowCount, 'file_size_mb' => $fileSizeMb, ]) ->track(fn () => $importer->process($file));
Metadata is stored as JSON on the model_usage_events row.
Tracking Policies
Every billable model can have a tracking policy that controls which features are tracked and at what cost multiplier. Policies are stored in the model_tracking_policies table (created by the package migration).
Creating a Policy
use LaravelCloudTracker\Models\TrackingPolicy; use LaravelCloudTracker\Enums\TrackingMode; // Track everything (default behavior even without a policy row) TrackingPolicy::create([ 'billable_type' => $org->getMorphClass(), 'billable_id' => $org->getKey(), 'tracking_mode' => TrackingMode::ALL, ]); // Track nothing TrackingPolicy::create([ 'billable_type' => $org->getMorphClass(), 'billable_id' => $org->getKey(), 'tracking_mode' => TrackingMode::NONE, ]); // Only track specific features TrackingPolicy::create([ 'billable_type' => $org->getMorphClass(), 'billable_id' => $org->getKey(), 'tracking_mode' => TrackingMode::ALLOWLIST, 'tracking_features' => ['segment_rebuild', 'csv_import', 'merge'], ]); // Track everything except listed features TrackingPolicy::create([ 'billable_type' => $org->getMorphClass(), 'billable_id' => $org->getKey(), 'tracking_mode' => TrackingMode::DENYLIST, 'tracking_features' => ['debug', 'health_check'], ]);
Or via the trait relationship:
$org->trackingPolicy()->create([ 'tracking_mode' => TrackingMode::ALL, 'usage_multiplier' => 0.75, // Enterprise discount ]);
Tracking Modes
| Mode | Behavior |
|---|---|
all |
Track every feature. This is also the default when no policy row exists. |
none |
Track nothing. The callback still executes, but no timing or DB writes occur. |
allowlist |
Only track features listed in tracking_features. |
denylist |
Track everything except features listed in tracking_features. |
Usage Multiplier
The usage_multiplier column scales all cost calculations for a model:
// Enterprise client at 75% rate $org->trackingPolicy()->update(['usage_multiplier' => 0.7500]); // At-cost client at 100% (default) $org->trackingPolicy()->update(['usage_multiplier' => 1.0000]); // Premium support client at 150% markup $org->trackingPolicy()->update(['usage_multiplier' => 1.5000]);
Policy Resolution
Policy is evaluated before timing starts. When tracking is disabled for a model+feature:
- The callback still executes normally
- No
hrtimecalls - No cost calculation
- No database writes
- Near-zero overhead
Policies are cached in memory for the duration of the request to avoid redundant queries.
Querying Usage Data
Via Relationships
// All events for an organization $org->usageEvents()->where('feature', 'merge')->get(); // Monthly rollups $org->usageRollups() ->where('period_start', '2026-02-01') ->get(); // Total cost this month $org->usageRollups() ->where('period_start', now()->startOfMonth()) ->sum('total_cost');
Via Models Directly
use LaravelCloudTracker\Models\UsageRollup; use LaravelCloudTracker\Models\UsageEvent; // Top 10 most expensive features this month UsageRollup::where('period_start', now()->startOfMonth()) ->orderByDesc('total_cost') ->limit(10) ->get(); // Recent events with cost breakdown UsageEvent::where('feature', 'segment_rebuild') ->latest() ->limit(50) ->get() ->each(function ($event) { // $event->cost_dimensions contains per-dimension breakdown: // [ // 'compute' => ['ms' => 150.23, 'cost' => 0.00000024], // 'cache' => ['quantity' => 25, 'cost' => 0.000015], // ] });
Extending the Package
Both the policy resolver and cost calculator are bound to contracts in the service container. You can swap in your own implementations.
Custom Policy Resolver
use LaravelCloudTracker\Contracts\TrackingPolicyResolver; class CustomPolicyResolver implements TrackingPolicyResolver { public function shouldTrack(Model $model, string $feature): bool { // Your custom logic — check feature flags, plan tiers, etc. } public function getMultiplier(Model $model): float { // Dynamic pricing based on plan, usage tier, time of day, etc. } public function flush(): void { // Clear any caches } }
Register in your AppServiceProvider:
$this->app->singleton( \LaravelCloudTracker\Contracts\TrackingPolicyResolver::class, \App\Services\CustomPolicyResolver::class, );
Custom Cost Calculator
use LaravelCloudTracker\Contracts\CostCalculator; class VolumeTierCostCalculator implements CostCalculator { public function calculate(float $executionTimeMs, array $dimensions, float $multiplier = 1.0): array { // Apply volume discounts, time-of-day pricing, etc. } }
Register in your AppServiceProvider:
$this->app->singleton( \LaravelCloudTracker\Contracts\CostCalculator::class, \App\Services\VolumeTierCostCalculator::class, );
How It Works
Execution Flow
CloudCost::for($model)->feature('x')->track(fn () => ...)
│
├─ Validate: model and feature are set
├─ Check: config enabled?
├─ Check: environment allowed?
├─ Check: policy allows tracking? (skipped if force())
│
├─ Start hrtime
├─ Execute callback
├─ Stop hrtime → execution_time_ms
│
├─ Resolve dimensions (default to config default_dimension)
├─ Calculate cost per dimension × usage_multiplier
│
├─ Write to model_usage_events (if log_events enabled)
├─ Atomic upsert to model_usage_rollups
│
└─ Return callback result
Cost Calculation
Each dimension type calculates cost differently:
| Unit Type | Formula | Examples |
|---|---|---|
time |
execution_time_ms ÷ 1000 × per_second_rate |
compute, postgres, queue |
count |
quantity × per_unit_rate |
bandwidth, storage |
flat_monthly |
quantity × (monthly_rate ÷ estimated_ops_per_month) |
cache, websocket |
The total cost across all dimensions is then multiplied by the model's usage_multiplier.
Rollup Aggregation
Rollups use an atomic database upsert keyed by (billable_type, billable_id, feature, period_start). On conflict, total_execution_ms, total_cost, and event_count are incremented atomically — no read-then-write race conditions.
Compatible with MySQL, PostgreSQL, and SQLite.
Testing
Running Package Tests
composer install ./vendor/bin/phpunit
Tests run against an in-memory SQLite database with no external dependencies.
Test Coverage
| Suite | Tests | Covers |
|---|---|---|
| Feature/TrackingPolicyTest | 8 | All policy modes, multiplier, caching, trait relationships |
| Feature/CloudCostTrackingTest | 18 | Full tracking lifecycle, force bypass, dimension chaining, environment/config disabling, timing accuracy, metadata, event logging toggle |
| Unit/CostCalculationTest | 11 | All dimension unit types, multiplier scaling, multi-dimension summation, edge cases, unknown dimension errors |
Testing in Your Application
The package respects the environments config. Add 'testing' to track during tests, or leave it out to skip tracking entirely in your test suite:
// config/cloud-tracker.php 'environments' => ['production', 'staging', 'testing'],
To assert tracking in your application tests:
use LaravelCloudTracker\Models\UsageEvent; use LaravelCloudTracker\Models\UsageRollup; // Assert an event was recorded $this->assertDatabaseHas('model_usage_events', [ 'billable_type' => $org->getMorphClass(), 'billable_id' => $org->getKey(), 'feature' => 'merge', ]); // Assert rollup was created $this->assertDatabaseHas('model_usage_rollups', [ 'feature' => 'merge', 'period_start' => now()->startOfMonth()->toDateString(), ]);
What This Package Is Not
- Not a billing engine. No Stripe, no invoices, no payment processing.
- Not a UI. No dashboards, charts, or admin panels.
- Not real infrastructure introspection. It doesn't read CloudWatch metrics or parse SQL queries. It estimates cost from execution time and configured rates.
- Not a rate limiter or quota enforcer. It observes — it doesn't restrict.
It is the observability foundation that makes all of those things possible.
License
MIT