Analytics Module Implementation Guide
Version: 2.0 (Gold Standard Aligned) Last Updated: 2025-01-19 Reference: Operations Module Status: 📋 Implementation Guide
Overview
The Analytics module provides business intelligence, KPI tracking, and reporting. While primarily read-heavy, it still follows CQRS patterns for consistency.
Gold Standard Reference: Follow Operations module patterns exactly.
Core Entities & Monetary Fields
Metric (MAY have Money-related values)
- current_value - Stored as float (metric calculation result)
- target_value - Stored as float (target threshold)
Note: Metrics calculate and store numeric results (including revenue/financial metrics) as floats, not Money VOs, since they're aggregated statistical values.
Dashboard (NO Money Fields)
- Configuration and layout data
Report (NO Money Fields)
- Report definitions and parameters
Module Structure
app/Modules/Analytics/
├── Domain/
│ ├── Models/
│ │ ├── Metric.php ✅ Rich domain logic
│ │ ├── Dashboard.php
│ │ ├── Widget.php
│ │ ├── Report.php
│ │ └── DataSource.php
│ ├── Enums/
│ │ ├── MetricType.php
│ │ ├── DashboardType.php
│ │ └── ReportFormat.php
│ ├── Events/
│ │ ├── MetricCalculated.php
│ │ ├── ThresholdExceeded.php
│ │ └── ReportGenerated.php
│ ├── Services/
│ │ ├── MetricCalculationService.php
│ │ └── ReportGenerationService.php
│ └── Contracts/
│ ├── MetricRepositoryInterface.php
│ └── DashboardRepositoryInterface.php
│
├── Application/
│ ├── Commands/
│ │ ├── CreateMetricCommand.php
│ │ ├── CalculateMetricCommand.php ✅ Calculate and store result
│ │ ├── UpdateMetricCommand.php
│ │ └── GenerateReportCommand.php
│ ├── Queries/
│ │ ├── GetMetricsQuery.php ✅ Read-heavy
│ │ ├── GetMetricByIdQuery.php
│ │ ├── GetDashboardQuery.php
│ │ └── GetMetricTimeSeriesQuery.php ✅ Historical data
│ ├── DTOs/
│ │ ├── Metric/
│ │ │ ├── CreateMetricDTO.php
│ │ │ └── MetricFilterDTO.php
│ │ └── Dashboard/
│ │ └── CreateDashboardDTO.php
│ └── Services/
│ ├── MetricService.php
│ └── DashboardService.php
│
└── Presentation/
├── Controllers/
│ ├── Metrics/
│ │ ├── MetricController.php
│ │ └── MetricCalculateController.php
│ └── Dashboards/
│ └── DashboardController.php
└── Resources/
├── MetricResource.php
└── DashboardResource.phpStep 1: Domain Layer
1.1 Create Enums
MetricType.php
<?php
declare(strict_types=1);
namespace App\Modules\Analytics\Domain\Enums;
enum MetricType: string
{
case COUNT = 'count';
case SUM = 'sum';
case AVERAGE = 'average';
case PERCENTAGE = 'percentage';
case RATIO = 'ratio';
case GROWTH_RATE = 'growth_rate';
case CUSTOM = 'custom';
}DashboardType.php
<?php
declare(strict_types=1);
namespace App\Modules\Analytics\Domain\Enums;
enum DashboardType: string
{
case EXECUTIVE = 'executive';
case OPERATIONAL = 'operational';
case ANALYTICAL = 'analytical';
case REAL_TIME = 'real_time';
}1.2 Create Domain Models
Metric.php
<?php
declare(strict_types=1);
namespace App\Modules\Analytics\Domain\Models;
use App\Modules\Analytics\Domain\Enums\MetricType;
use App\Modules\Analytics\Domain\Events\MetricCalculated;
use App\Modules\Analytics\Domain\Events\ThresholdExceeded;
use Spatie\Multitenancy\Models\Concerns\UsesTenantConnection;
use Illuminate\Database\Eloquent\Model;
/**
* @property string $name
* @property MetricType $type
* @property float|null $currentValue
* @property float|null $targetValue
* @property array $thresholds
* @property bool $isActive
*/
final class Metric extends Model
{
use HasFactory, UsesTenantConnection, Auditable;
protected $table = 'analytics_metrics';
protected $fillable = [
'name',
'description',
'type',
'current_value',
'target_value',
'thresholds',
'configuration',
'is_active',
];
protected function casts(): array
{
return [
'type' => MetricType::class,
'current_value' => 'float',
'target_value' => 'float',
'thresholds' => 'array',
'configuration' => 'array',
'is_active' => 'boolean',
'last_calculated_at' => 'datetime',
];
}
// ✅ Domain method to calculate metric
public function calculate(): self
{
if (!$this->isActive) {
throw new InactiveMetricException('Cannot calculate inactive metric');
}
$previousValue = $this->currentValue;
// Execute calculation based on configuration
$newValue = $this->executeCalculation();
$this->currentValue = $newValue;
$this->last_calculated_at = now();
$this->save();
// Check thresholds
$this->checkThresholds($previousValue, $newValue);
MetricCalculated::dispatch(
$this->id,
$this->name,
$newValue,
$previousValue,
now()
);
return $this;
}
// ✅ Domain method to update configuration
public function updateConfiguration(array $configuration): self
{
$this->configuration = $configuration;
$this->save();
return $this;
}
// ✅ Calculate based on metric type
private function executeCalculation(): float
{
$config = $this->configuration;
return match ($this->type) {
MetricType::COUNT => $this->calculateCount($config),
MetricType::SUM => $this->calculateSum($config),
MetricType::AVERAGE => $this->calculateAverage($config),
MetricType::PERCENTAGE => $this->calculatePercentage($config),
MetricType::RATIO => $this->calculateRatio($config),
MetricType::GROWTH_RATE => $this->calculateGrowthRate($config),
MetricType::CUSTOM => $this->calculateCustom($config),
};
}
private function calculateSum(array $config): float
{
// Example: Sum of order totals
$model = $config['model'] ?? null;
$field = $config['field'] ?? null;
if (!$model || !$field) {
return 0.0;
}
// Query the model
$query = app($model)::query();
// Apply filters if provided
if (isset($config['filters'])) {
foreach ($config['filters'] as $filter => $value) {
$query->where($filter, $value);
}
}
// For Money VO fields, we need to sum the 'amount' from JSON
if (isset($config['is_money_field']) && $config['is_money_field']) {
// Sum cents and convert to dollars
$totalCents = $query->sum(\DB::raw("JSON_EXTRACT({$field}, '$.amount')"));
return $totalCents / 100;
}
return (float) $query->sum($field);
}
private function checkThresholds(?float $previousValue, float $newValue): void
{
if (empty($this->thresholds)) {
return;
}
// Check if critical threshold exceeded
if (isset($this->thresholds['critical']) && $newValue >= $this->thresholds['critical']) {
ThresholdExceeded::dispatch(
$this->id,
$this->name,
'critical',
$newValue,
$this->thresholds['critical'],
now()
);
}
// Check if warning threshold exceeded
elseif (isset($this->thresholds['warning']) && $newValue >= $this->thresholds['warning']) {
ThresholdExceeded::dispatch(
$this->id,
$this->name,
'warning',
$newValue,
$this->thresholds['warning'],
now()
);
}
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
}Dashboard.php
<?php
declare(strict_types=1);
namespace App\Modules\Analytics\Domain\Models;
use App\Modules\Analytics\Domain\Enums\DashboardType;
use App\Modules\Analytics\Domain\Events\DashboardCreated;
use Spatie\Multitenancy\Models\Concerns\UsesTenantConnection;
final class Dashboard extends Model
{
use HasFactory, UsesTenantConnection, Auditable;
protected $table = 'analytics_dashboards';
protected $fillable = [
'name',
'type',
'layout',
'widgets',
'is_public',
'created_by',
];
protected function casts(): array
{
return [
'type' => DashboardType::class,
'layout' => 'array',
'widgets' => 'array',
'is_public' => 'boolean',
];
}
protected static function boot()
{
parent::boot();
static::created(function ($dashboard) {
DashboardCreated::dispatch(
$dashboard->id,
$dashboard->name,
$dashboard->type,
now()
);
});
}
}Step 2: Application Layer - CQRS
2.1 Commands
CreateMetricCommand.php
<?php
declare(strict_types=1);
namespace App\Modules\Analytics\Application\Commands;
use App\Shared\Application\Commands\Command;
use App\Modules\Analytics\Application\DTOs\Metric\CreateMetricDTO;
final class CreateMetricCommand implements Command
{
public function __construct(
public readonly CreateMetricDTO $dto,
) {}
public function validate(): array
{
return [];
}
public static function fromArray(array $data): self
{
return new self(
dto: CreateMetricDTO::fromArray($data),
);
}
}CreateMetricCommandHandler.php
<?php
declare(strict_types=1);
namespace App\Modules\Analytics\Application\Commands;
use App\Shared\Application\Commands\Command;
use App\Shared\Application\Commands\CommandHandler;
use App\Modules\Analytics\Domain\Contracts\MetricRepositoryInterface;
use App\Modules\Analytics\Domain\Models\Metric;
final class CreateMetricCommandHandler implements CommandHandler
{
public function __construct(
private readonly MetricRepositoryInterface $metricRepository,
) {}
public function handle(Command $command): Metric
{
\assert($command instanceof CreateMetricCommand);
$dto = $command->dto;
$metric = new Metric([
'name' => $dto->name,
'description' => $dto->description,
'type' => $dto->type,
'target_value' => $dto->targetValue,
'thresholds' => $dto->thresholds,
'configuration' => $dto->configuration,
'is_active' => true,
]);
$this->metricRepository->save($metric);
return $metric;
}
}CalculateMetricCommand.php
<?php
declare(strict_types=1);
namespace App\Modules\Analytics\Application\Commands;
use App\Shared\Application\Commands\Command;
final class CalculateMetricCommand implements Command
{
public function __construct(
public readonly int $metricId,
) {}
public function validate(): array
{
return [];
}
}CalculateMetricCommandHandler.php
<?php
declare(strict_types=1);
namespace App\Modules\Analytics\Application\Commands;
use App\Shared\Application\Commands\Command;
use App\Shared\Application\Commands\CommandHandler;
use App\Modules\Analytics\Domain\Contracts\MetricRepositoryInterface;
use App\Modules\Analytics\Domain\Models\Metric;
final class CalculateMetricCommandHandler implements CommandHandler
{
public function __construct(
private readonly MetricRepositoryInterface $metricRepository,
) {}
public function handle(Command $command): Metric
{
\assert($command instanceof CalculateMetricCommand);
$metric = $this->metricRepository->findOrFail($command->metricId);
// ✅ Event dispatched inside calculate() method
$metric->calculate();
return $metric;
}
}2.2 Queries (Analytics is read-heavy)
GetMetricsQuery.php
<?php
declare(strict_types=1);
namespace App\Modules\Analytics\Application\Queries;
use App\Shared\Application\Queries\Query;
use App\Modules\Analytics\Application\DTOs\Metric\MetricFilterDTO;
final class GetMetricsQuery implements Query
{
public function __construct(
public readonly MetricFilterDTO $filters,
) {}
public function validate(): array
{
return [];
}
}GetMetricsQueryHandler.php
<?php
declare(strict_types=1);
namespace App\Modules\Analytics\Application\Queries;
use App\Shared\Application\Queries\Query;
use App\Shared\Application\Queries\QueryHandler;
use App\Modules\Analytics\Domain\Contracts\MetricRepositoryInterface;
final class GetMetricsQueryHandler implements QueryHandler
{
public function __construct(
private readonly MetricRepositoryInterface $metricRepository,
) {}
public function handle(Query $query)
{
\assert($query instanceof GetMetricsQuery);
return $this->metricRepository->getPaginated($query->filters);
}
}Step 3: Presentation Layer
MetricController.php
<?php
declare(strict_types=1);
namespace App\Modules\Analytics\Presentation\Controllers\Metrics;
use App\Http\Controllers\Controller;
use App\Shared\Application\Bus\CommandBus;
use App\Shared\Application\Bus\QueryBus;
use App\Modules\Analytics\Application\Commands\CreateMetricCommand;
use App\Modules\Analytics\Application\Queries\GetMetricsQuery;
use App\Modules\Analytics\Application\DTOs\Metric\MetricFilterDTO;
use App\Modules\Analytics\Presentation\Requests\CreateMetricRequest;
use App\Modules\Analytics\Presentation\Requests\IndexMetricRequest;
use App\Modules\Analytics\Presentation\Resources\MetricResource;
use App\Modules\Analytics\Presentation\Resources\MetricCollection;
use Illuminate\Http\JsonResponse;
final class MetricController extends Controller
{
public function __construct(
private readonly CommandBus $commandBus,
private readonly QueryBus $queryBus,
) {}
// ✅ Read operation - uses Query
public function index(IndexMetricRequest $request): MetricCollection
{
$filterDTO = MetricFilterDTO::fromArray($request->getFilters());
$query = new GetMetricsQuery($filterDTO);
$metrics = $this->queryBus->dispatch($query);
return new MetricCollection($metrics);
}
// ✅ Write operation - uses Command
public function store(CreateMetricRequest $request): JsonResponse
{
$command = CreateMetricCommand::fromArray($request->validated());
$metric = $this->commandBus->dispatch($command);
return \response()->json([
'message' => 'Metric created successfully',
'data' => new MetricResource($metric),
], 201);
}
}MetricCalculateController.php (Single-action)
<?php
declare(strict_types=1);
namespace App\Modules\Analytics\Presentation\Controllers\Metrics;
use App\Http\Controllers\Controller;
use App\Shared\Application\Bus\CommandBus;
use App\Modules\Analytics\Application\Commands\CalculateMetricCommand;
use App\Modules\Analytics\Domain\Models\Metric;
use App\Modules\Analytics\Presentation\Resources\MetricResource;
use Illuminate\Http\JsonResponse;
final class MetricCalculateController extends Controller
{
public function __construct(
private readonly CommandBus $commandBus,
) {}
public function __invoke(Metric $metric): JsonResponse
{
$command = new CalculateMetricCommand($metric->id);
$metric = $this->commandBus->dispatch($command);
return \response()->json([
'message' => 'Metric calculated successfully',
'data' => new MetricResource($metric),
]);
}
}Step 4: Migrations
create_analytics_metrics_table.php
Schema::create('analytics_metrics', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->string('type');
// Values stored as floats (NOT Money VO)
$table->decimal('current_value', 20, 4)->nullable();
$table->decimal('target_value', 20, 4)->nullable();
$table->json('thresholds')->nullable();
$table->json('configuration');
$table->boolean('is_active')->default(true);
$table->timestamp('last_calculated_at')->nullable();
$table->timestamps();
});Testing
public function test_metric_calculates_sum_correctly(): void
{
Event::fake();
// Create test orders with Money VOs
Order::factory()->create([
'total_amount' => ['amount' => 10000, 'currency' => 'USD'], // $100
]);
Order::factory()->create([
'total_amount' => ['amount' => 15000, 'currency' => 'USD'], // $150
]);
$metric = Metric::factory()->create([
'type' => MetricType::SUM,
'configuration' => [
'model' => Order::class,
'field' => 'total_amount',
'is_money_field' => true, // ✅ Tells metric to handle Money VO
],
]);
$metric->calculate();
// ✅ Should sum to $250
$this->assertEquals(250.00, $metric->current_value);
Event::assertDispatched(MetricCalculated::class);
}
public function test_metric_triggers_threshold_alert(): void
{
Event::fake();
$metric = Metric::factory()->create([
'type' => MetricType::COUNT,
'current_value' => 0,
'thresholds' => [
'warning' => 100,
'critical' => 200,
],
'configuration' => [
'model' => Order::class,
],
]);
// Simulate calculation that exceeds threshold
$metric->currentValue = 250;
$metric->save();
$metric->calculate();
Event::assertDispatched(ThresholdExceeded::class);
}Special Considerations for Analytics
Handling Money VOs in Metrics
When calculating metrics from Money VO fields:
// In metric configuration
[
'model' => Order::class,
'field' => 'total_amount',
'is_money_field' => true, // ✅ Flag to handle Money VO
]
// In calculation
if (isset($config['is_money_field']) && $config['is_money_field']) {
// Query the 'amount' from JSON (stored in cents)
$totalCents = $query->sum(\DB::raw("JSON_EXTRACT({$field}, '$.amount')"));
return $totalCents / 100; // Convert to dollars
}Integration with Other Modules (Event-Driven)
Analytics listens to events from other modules to update metrics:
// In app/Modules/Analytics/Infrastructure/Listeners/UpdateRevenueMetric.php
final class UpdateRevenueMetric
{
public function handle(OrderConfirmed $event): void
{
// Find revenue metric
$metric = Metric::where('name', 'monthly_revenue')->first();
if ($metric) {
// Trigger recalculation via Command
$command = new CalculateMetricCommand($metric->id);
$this->commandBus->dispatch($command);
}
}
}Implementation Checklist
- [ ] Create
MetricType,DashboardTypeenums - [ ] Create
Metricmodel with calculation logic - [ ] Create
CreateMetricCommandand handler - [ ] Create
CalculateMetricCommandand handler - [ ] Create
GetMetricsQueryand handler - [ ] Handle Money VO fields in metric calculations
- [ ] Create event listeners for auto-recalculation
- [ ] Test metric calculations
- [ ] Test threshold alerting
Reference: docs/implementation/operations-module-v2.md