Skip to content

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

  • 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.php

Step 1: Domain Layer

1.1 Create Enums

MetricType.php

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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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

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

php
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:

php
// 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:

php
// 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, DashboardType enums
  • [ ] Create Metric model with calculation logic
  • [ ] Create CreateMetricCommand and handler
  • [ ] Create CalculateMetricCommand and handler
  • [ ] Create GetMetricsQuery and 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

Documentation for SynthesQ CRM/ERP Platform