<?php
 namespace App\Observers; use App\Models\Log; use Illuminate\Support\Facades\DB; use RuntimeException; class LogObserver { public static bool $bypassImmutability = false; public function creating(Log $log): void { if ($log->created_at === null) { $log->created_at = now(); $log->updated_at = $log->updated_at ?? $log->created_at; } DB::transaction(function () use ($log) { $previous = Log::query() ->orderByDesc('id') ->limit(1) ->lockForUpdate() ->first(['id', 'row_hash']); $prevHash = $previous?->row_hash; $log->prev_hash = $prevHash; $log->row_hash = self::computeRowHash($log, $prevHash); }); } public function updating(Log $log): void { if (self::$bypassImmutability) { return; } throw new RuntimeException( 'Log table is append-only (PRC-194). Updates are not permitted — log_id='.$log->id ); } public function deleting(Log $log): void { if (self::$bypassImmutability) { return; } throw new RuntimeException( 'Log table is append-only (PRC-194). Deletes are not permitted — log_id='.$log->id ); } public static function computeRowHash(Log $log, ?string $prevHash): string { $payload = [ 'prev_hash' => $prevHash ?? '', 'observer' => (string) ($log->observer ?? ''), 'module' => (string) ($log->module ?? ''), 'type' => (string) ($log->type ?? ''), 'client_id' => $log->client_id !== null ? (int) $log->client_id : null, 'admin_id' => $log->admin_id !== null ? (int) $log->admin_id : null, 'contact_id' => $log->contact_id !== null ? (int) $log->contact_id : null, 'ip_address' => (string) ($log->ip_address ?? ''), 'method' => (string) ($log->method ?? ''), 'description' => (string) ($log->description ?? ''), 'request' => self::canonicalJson($log->getAttribute('request')), 'response' => self::canonicalJson($log->getAttribute('response')), 'elapsed_ms' => $log->elapsed_ms !== null ? (int) $log->elapsed_ms : null, 'user_agent' => (string) ($log->user_agent ?? ''), 'session_id' => (string) ($log->session_id ?? ''), 'created_at' => optional($log->created_at)->toIso8601String() ?? '', ]; return hash('sha256', json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); } protected static function canonicalJson(mixed $value): string { if ($value === null) { return ''; } if (is_string($value)) { $decoded = json_decode($value, true); if (json_last_error() !== JSON_ERROR_NONE) { return $value; } $value = $decoded; } if (is_array($value)) { self::ksortRecursive($value); return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: ''; } return (string) $value; } protected static function ksortRecursive(array &$array): void { foreach ($array as &$value) { if (is_array($value)) { self::ksortRecursive($value); } } unset($value); ksort($array); } } 