feat(Production): Complete production deployment infrastructure

- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -47,7 +47,7 @@ final readonly class AdminApiHandler
// Convert to arrays
$items = array_map(
fn($item) => method_exists($item, 'toArray') ? $item->toArray() : (array) $item,
fn ($item) => method_exists($item, 'toArray') ? $item->toArray() : (array) $item,
$paginatedData
);
@@ -147,7 +147,7 @@ final readonly class AdminApiHandler
try {
$deleted = $repository->delete($id);
if (!$deleted) {
if (! $deleted) {
return new JsonResult([
'success' => false,
'error' => 'Resource not found',

View File

@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Framework\Admin\Factories;
use App\Framework\Admin\ValueObjects\AdminFormConfig;
use App\Framework\Admin\FormFields\FormFieldFactory;
use App\Framework\Admin\ValueObjects\AdminFormConfig;
use App\Framework\Http\Session\FormIdGenerator;
use App\Framework\View\FormBuilder;
@@ -19,7 +19,8 @@ final readonly class AdminFormFactory
public function __construct(
private FormIdGenerator $formIdGenerator,
private FormFieldFactory $fieldFactory
) {}
) {
}
public function create(AdminFormConfig $config): FormBuilder
{
@@ -36,7 +37,7 @@ final readonly class AdminFormFactory
$fieldConfigWithValue = [
...$fieldConfig,
'name' => $name,
'value' => $config->data[$name] ?? null
'value' => $config->data[$name] ?? null,
];
// Create field using factory

View File

@@ -5,9 +5,9 @@ declare(strict_types=1);
namespace App\Framework\Admin\Factories;
use App\Framework\Admin\ValueObjects\AdminTableConfig;
use App\Framework\View\Table\Formatters;
use App\Framework\View\Table\Table;
use App\Framework\View\Table\TableOptions;
use App\Framework\View\Table\Formatters;
/**
* Admin Table Factory

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Framework\Admin\FormFields\Fields;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\Admin\FormFields\FormField;
use App\Framework\Admin\FormFields\ValueObjects\FieldAttributes;
use App\Framework\Admin\FormFields\ValueObjects\FieldMetadata;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\View\FormBuilder;
use App\Framework\View\ValueObjects\FormElement;
@@ -24,7 +24,8 @@ final readonly class CheckboxField implements FormField
private FieldWrapper $wrapper,
private mixed $value = null,
private string $checkedValue = '1'
) {}
) {
}
public static function create(
string $name,

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Framework\Admin\FormFields\Fields;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\Admin\FormFields\FormField;
use App\Framework\Admin\FormFields\ValueObjects\FieldAttributes;
use App\Framework\Admin\FormFields\ValueObjects\FieldMetadata;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\View\FormBuilder;
use App\Framework\View\ValueObjects\FormElement;
use DateTimeInterface;
@@ -25,7 +25,8 @@ final readonly class DateTimeField implements FormField
private FieldWrapper $wrapper,
private mixed $value = null,
private string $type = 'datetime-local'
) {}
) {
}
public static function create(
string $name,

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Framework\Admin\FormFields\Fields;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\Admin\FormFields\FormField;
use App\Framework\Admin\FormFields\ValueObjects\FieldAttributes;
use App\Framework\Admin\FormFields\ValueObjects\FieldMetadata;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\View\FormBuilder;
use App\Framework\View\ValueObjects\FormElement;
@@ -23,7 +23,8 @@ final readonly class EmailField implements FormField
private FieldAttributes $attributes,
private FieldWrapper $wrapper,
private mixed $value = null
) {}
) {
}
public static function create(
string $name,

View File

@@ -21,7 +21,8 @@ final readonly class HiddenField implements FormField
private FieldMetadata $metadata,
private FieldAttributes $attributes,
private mixed $value = null
) {}
) {
}
public static function create(
string $name,

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Framework\Admin\FormFields\Fields;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\Admin\FormFields\FormField;
use App\Framework\Admin\FormFields\ValueObjects\FieldAttributes;
use App\Framework\Admin\FormFields\ValueObjects\FieldMetadata;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\View\FormBuilder;
use App\Framework\View\ValueObjects\FormElement;
@@ -26,7 +26,8 @@ final readonly class NumberField implements FormField
private ?int $min = null,
private ?int $max = null,
private ?int $step = null
) {}
) {
}
public static function create(
string $name,

View File

@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Framework\Admin\FormFields\Fields;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\Admin\FormFields\FormField;
use App\Framework\Admin\FormFields\ValueObjects\FieldAttributes;
use App\Framework\Admin\FormFields\ValueObjects\FieldMetadata;
use App\Framework\Admin\FormFields\ValueObjects\FieldOptions;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\View\FormBuilder;
use App\Framework\View\ValueObjects\FormElement;
@@ -25,7 +25,8 @@ final readonly class SelectField implements FormField
private FieldWrapper $wrapper,
private FieldOptions $options,
private mixed $value = null
) {}
) {
}
public static function create(
string $name,

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Framework\Admin\FormFields\Fields;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\Admin\FormFields\FormField;
use App\Framework\Admin\FormFields\ValueObjects\FieldAttributes;
use App\Framework\Admin\FormFields\ValueObjects\FieldMetadata;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\View\FormBuilder;
use App\Framework\View\ValueObjects\FormElement;
@@ -23,7 +23,8 @@ final readonly class TextField implements FormField
private FieldAttributes $attributes,
private FieldWrapper $wrapper,
private mixed $value = null
) {}
) {
}
public static function create(
string $name,

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Framework\Admin\FormFields\Fields;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\Admin\FormFields\FormField;
use App\Framework\Admin\FormFields\ValueObjects\FieldAttributes;
use App\Framework\Admin\FormFields\ValueObjects\FieldMetadata;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\View\FormBuilder;
use App\Framework\View\ValueObjects\FormElement;
@@ -24,7 +24,8 @@ final readonly class TextareaField implements FormField
private FieldWrapper $wrapper,
private mixed $value = null,
private int $rows = 5
) {}
) {
}
public static function create(
string $name,

View File

@@ -4,16 +4,16 @@ declare(strict_types=1);
namespace App\Framework\Admin\FormFields;
use App\Framework\Admin\FormFields\Fields\TextField;
use App\Framework\Admin\FormFields\Fields\EmailField;
use App\Framework\Admin\FormFields\Fields\NumberField;
use App\Framework\Admin\FormFields\Fields\TextareaField;
use App\Framework\Admin\FormFields\Fields\SelectField;
use App\Framework\Admin\FormFields\Fields\DateTimeField;
use App\Framework\Admin\FormFields\Fields\CheckboxField;
use App\Framework\Admin\FormFields\Fields\DateTimeField;
use App\Framework\Admin\FormFields\Fields\EmailField;
use App\Framework\Admin\FormFields\Fields\HiddenField;
use App\Framework\Exception\FrameworkException;
use App\Framework\Admin\FormFields\Fields\NumberField;
use App\Framework\Admin\FormFields\Fields\SelectField;
use App\Framework\Admin\FormFields\Fields\TextareaField;
use App\Framework\Admin\FormFields\Fields\TextField;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
/**
* Form Field Factory
@@ -140,7 +140,7 @@ final readonly class FormFieldFactory
public function createMultiple(array $fieldsConfig): array
{
return array_map(
fn(array $config) => $this->createFromConfig($config),
fn (array $config) => $this->createFromConfig($config),
$fieldsConfig
);
}

View File

@@ -21,7 +21,8 @@ final readonly class FieldAttributes
public bool $required = false,
public ?string $placeholder = null,
public array $additional = []
) {}
) {
}
/**
* Convert attributes to array for rendering
@@ -34,7 +35,7 @@ final readonly class FieldAttributes
'name' => $this->name,
'id' => $this->id,
'class' => $this->class,
...$this->additional
...$this->additional,
];
if ($this->required) {

View File

@@ -15,5 +15,6 @@ final readonly class FieldMetadata
public string $name,
public string $label,
public ?string $help = null
) {}
) {
}
}

View File

@@ -18,7 +18,8 @@ final readonly class FieldOptions
public function __construct(
public array $options,
public ?string $placeholder = null
) {}
) {
}
/**
* Convert options to HTML option elements

View File

@@ -23,7 +23,8 @@ final readonly class CrudService
public function __construct(
private TemplateRenderer $renderer,
private AdminFormFactory $formFactory
) {}
) {
}
/**
* Render index view

View File

@@ -36,7 +36,8 @@ final readonly class CrudConfig
public ?array $filters = null,
public ?array $bulkActions = null,
public bool $searchable = true,
) {}
) {
}
public static function forResource(
string $resource,

View File

@@ -55,8 +55,8 @@ final class AnalyticsCollector
'category' => $category->value,
'session_id' => $this->getSessionId(),
'timestamp' => time(),
'user_agent' => $this->serverEnvironment->getUserAgent()->value,
'ip' => $this->serverEnvironment->getClientIp()->value,
'user_agent' => (string) $this->serverEnvironment->getUserAgent(),
'ip' => (string) $this->serverEnvironment->getClientIp(),
], $properties);
// Nutze Performance-System für Analytics
@@ -136,7 +136,7 @@ final class AnalyticsCollector
'message' => $message,
'session_id' => $this->getSessionId(),
'timestamp' => time(),
'url' => $this->serverEnvironment->getRequestUri()->value,
'url' => (string) $this->serverEnvironment->getRequestUri(),
], $context);
$this->performanceCollector->recordMetric(
@@ -307,11 +307,9 @@ final class AnalyticsCollector
return $_COOKIE['analytics_session'];
}
// Generiere neue Session-ID
$sessionId = bin2hex($this->random->bytes(16));
setcookie('analytics_session', $sessionId, time() + 1800); // 30 Min
return $sessionId;
// Generiere neue Session-ID (Cookie-Setzung erfolgt über Response-System)
// Hier nur ID generieren, nicht setzen um "headers already sent" zu vermeiden
return bin2hex($this->random->bytes(16));
}
/**

View File

@@ -5,166 +5,191 @@ declare(strict_types=1);
namespace App\Framework\Async;
use Fiber;
use SplQueue;
use Throwable;
/**
* Channel für Kommunikation zwischen Fibers
* Go-Style Channel für asynchrone Producer-Consumer Patterns
*
* Ermöglicht sichere Kommunikation zwischen Fibers mit automatischer
* Blockierung bei vollen/leeren Channels.
*/
final class AsyncChannel
{
/** @var array<mixed> */
private array $buffer = [];
/** @var SplQueue<mixed> */
private SplQueue $buffer;
/** @var array<Fiber> */
private array $waitingSenders = [];
/** @var array<Fiber> Wartende Producer */
private array $waitingProducers = [];
/** @var array<Fiber> */
private array $waitingReceivers = [];
/** @var array<Fiber> Wartende Consumer */
private array $waitingConsumers = [];
private bool $closed = false;
public function __construct(
private readonly int $bufferSize = 0 // 0 = unbuffered (synchronous)
private readonly int $capacity = 0 // 0 = unbuffered (synchronous)
) {
$this->buffer = new SplQueue();
}
/**
* Sendet einen Wert über den Channel
* Sendet einen Wert in den Channel
*
* Blockiert, wenn der Channel voll ist (buffered) oder kein Consumer wartet (unbuffered)
*
* @throws ChannelClosedException|Throwable wenn Channel geschlossen ist
*/
public function send(mixed $value): bool
public function send(mixed $value): void
{
if ($this->closed) {
return false;
throw new ChannelClosedException('Cannot send on closed channel');
}
// Unbuffered channel - direkter Transfer
if ($this->bufferSize === 0) {
if (! empty($this->waitingReceivers)) {
$receiver = array_shift($this->waitingReceivers);
if (! $receiver->isTerminated()) {
$receiver->resume($value);
return true;
}
// Buffered channel: Warte bis Platz frei
if ($this->capacity > 0) {
while ($this->buffer->count() >= $this->capacity && !$this->closed) {
$this->waitingProducers[] = Fiber::getCurrent();
Fiber::suspend();
}
// Kein wartender Receiver - Sender muss warten
$fiber = new Fiber(function () use ($value) {
while (empty($this->waitingReceivers) && ! $this->closed) {
Fiber::suspend();
}
if (! empty($this->waitingReceivers)) {
$receiver = array_shift($this->waitingReceivers);
if (! $receiver->isTerminated()) {
$receiver->resume($value);
return true;
}
}
return false;
});
$this->waitingSenders[] = $fiber;
$fiber->start();
return $fiber->getReturn();
}
// Buffered channel
if (count($this->buffer) < $this->bufferSize) {
$this->buffer[] = $value;
// Wecke wartende Receiver auf
if (! empty($this->waitingReceivers)) {
$receiver = array_shift($this->waitingReceivers);
if (! $receiver->isTerminated()) {
$receiver->resume(array_shift($this->buffer));
}
if ($this->closed) {
throw new ChannelClosedException('Channel was closed while waiting');
}
return true;
}
$this->buffer->enqueue($value);
return false; // Buffer voll
// Wecke wartende Consumer auf
$this->resumeWaitingConsumers();
} else {
// Unbuffered channel: Direkter Transfer
if (empty($this->waitingConsumers)) {
// Kein Consumer wartet, Producer muss warten
$this->buffer->enqueue($value);
$this->waitingProducers[] = Fiber::getCurrent();
Fiber::suspend();
} else {
// Consumer wartet, direkter Transfer
$consumer = array_shift($this->waitingConsumers);
$this->buffer->enqueue($value);
if ($consumer instanceof Fiber && $consumer->isSuspended()) {
$consumer->resume();
}
}
}
}
/**
* Empfängt einen Wert vom Channel
*
* Blockiert wenn Channel leer ist
*
* @throws ChannelClosedException wenn Channel geschlossen und leer ist
*/
public function receive(): mixed
{
if (! empty($this->buffer)) {
$value = array_shift($this->buffer);
// Wecke wartende Sender auf
if (! empty($this->waitingSenders)) {
$sender = array_shift($this->waitingSenders);
if (! $sender->isTerminated()) {
$sender->resume();
}
}
return $value;
}
if ($this->closed) {
return null;
}
// Warte auf Wert
$fiber = new Fiber(function () {
while (empty($this->buffer) && ! $this->closed) {
// Buffered channel: Warte auf Daten
if ($this->capacity > 0) {
while ($this->buffer->isEmpty() && !$this->closed) {
$this->waitingConsumers[] = Fiber::getCurrent();
Fiber::suspend();
}
if (! empty($this->buffer)) {
return array_shift($this->buffer);
if ($this->buffer->isEmpty() && $this->closed) {
throw new ChannelClosedException('Channel is closed and empty');
}
return null;
});
$value = $this->buffer->dequeue();
$this->waitingReceivers[] = $fiber;
$fiber->start();
// Wecke wartende Producer auf
$this->resumeWaitingProducers();
return $fiber->getReturn();
return $value;
} else {
// Unbuffered channel: Warte auf Producer
while ($this->buffer->isEmpty() && !$this->closed) {
$this->waitingConsumers[] = Fiber::getCurrent();
Fiber::suspend();
}
if ($this->buffer->isEmpty() && $this->closed) {
throw new ChannelClosedException('Channel is closed and empty');
}
$value = $this->buffer->dequeue();
// Wecke wartende Producer auf
$this->resumeWaitingProducers();
return $value;
}
}
/**
* Versucht zu empfangen (non-blocking)
* Versucht einen Wert zu empfangen ohne zu blockieren
*
* @return array{success: bool, value: mixed}
*/
public function tryReceive(): mixed
public function tryReceive(): array
{
if (empty($this->buffer)) {
return null;
if ($this->buffer->isEmpty()) {
return ['success' => false, 'value' => null];
}
return array_shift($this->buffer);
$value = $this->buffer->dequeue();
$this->resumeWaitingProducers();
return ['success' => true, 'value' => $value];
}
/**
* Versucht einen Wert zu senden ohne zu blockieren
*/
public function trySend(mixed $value): bool
{
if ($this->closed) {
throw new ChannelClosedException('Cannot send on closed channel');
}
if ($this->capacity > 0 && $this->buffer->count() >= $this->capacity) {
return false; // Channel voll
}
$this->buffer->enqueue($value);
$this->resumeWaitingConsumers();
return true;
}
/**
* Schließt den Channel
*
* Weitere send() Operationen werden fehlschlagen
* Wartende Fibers werden aufgeweckt
*/
public function close(): void
{
$this->closed = true;
// Wecke alle wartenden Fibers auf
foreach ($this->waitingSenders as $sender) {
if (! $sender->isTerminated()) {
$sender->resume();
}
}
$this->resumeWaitingProducers();
$this->resumeWaitingConsumers();
}
foreach ($this->waitingReceivers as $receiver) {
if (! $receiver->isTerminated()) {
$receiver->resume(null);
}
}
/**
* Prüft ob Channel geschlossen ist
*/
public function isClosed(): bool
{
return $this->closed;
}
$this->waitingSenders = [];
$this->waitingReceivers = [];
/**
* Gibt die aktuelle Anzahl der Elemente im Buffer zurück
*/
public function count(): int
{
return $this->buffer->count();
}
/**
@@ -173,11 +198,42 @@ final class AsyncChannel
public function getStats(): array
{
return [
'buffer_size' => $this->bufferSize,
'buffered_items' => count($this->buffer),
'waiting_senders' => count($this->waitingSenders),
'waiting_receivers' => count($this->waitingReceivers),
'closed' => $this->closed,
'capacity' => $this->capacity,
'buffered_items' => $this->buffer->count(),
'waiting_producers' => count($this->waitingProducers),
'waiting_consumers' => count($this->waitingConsumers),
'is_closed' => $this->closed,
];
}
private function resumeWaitingConsumers(): void
{
while (!empty($this->waitingConsumers) && !$this->buffer->isEmpty()) {
$consumer = array_shift($this->waitingConsumers);
if ($consumer instanceof Fiber && $consumer->isSuspended()) {
$consumer->resume();
}
}
}
private function resumeWaitingProducers(): void
{
if ($this->capacity > 0) {
// Buffered: Wecke auf wenn Platz frei
while (!empty($this->waitingProducers) && $this->buffer->count() < $this->capacity) {
$producer = array_shift($this->waitingProducers);
if ($producer instanceof Fiber && $producer->isSuspended()) {
$producer->resume();
}
}
} else {
// Unbuffered: Wecke erste wartende Producer auf
if (!empty($this->waitingProducers)) {
$producer = array_shift($this->waitingProducers);
if ($producer instanceof Fiber && $producer->isSuspended()) {
$producer->resume();
}
}
}
}
}

View File

@@ -17,7 +17,7 @@ final class AsyncMutex
/** @var SplQueue<Fiber> */
private SplQueue $waitingFibers;
private ?string $owner = null;
private ?int $owner = null;
public function __construct(
private readonly string $name = ''

View File

@@ -22,8 +22,8 @@ final class AsyncPool
private array $results = [];
public function __construct(
private readonly int $maxConcurrency = 10,
private readonly FiberManager $fiberManager = new FiberManager()
private readonly FiberManager $fiberManager,
private readonly int $maxConcurrency = 10
) {
$this->pendingOperations = new SplQueue();
}
@@ -65,8 +65,13 @@ final class AsyncPool
private function startFiber(string $id, callable $operation): void
{
$fiber = $this->fiberManager->async($operation, $id);
$this->activeFibers[$id] = $fiber;
try {
$fiber = $this->fiberManager->async($operation, $id);
$this->activeFibers[$id] = $fiber;
} catch (\Throwable $e) {
// If fiber throws immediately on start, store as result
$this->results[$id] = $e;
}
}
private function collectCompletedFibers(): void

View File

@@ -25,16 +25,16 @@ final class AsyncPromise
private array $finallyCallbacks = [];
public function __construct(
private readonly FiberManager $fiberManager = new FiberManager()
private readonly FiberManager $fiberManager
) {
}
/**
* Erstellt ein resolved Promise
*/
public static function resolve(mixed $value): self
public static function resolve(mixed $value, FiberManager $fiberManager): self
{
$promise = new self();
$promise = new self($fiberManager);
$promise->result = $value;
$promise->resolved = true;
@@ -44,9 +44,9 @@ final class AsyncPromise
/**
* Erstellt ein rejected Promise
*/
public static function reject(\Throwable $exception): self
public static function reject(\Throwable $exception, FiberManager $fiberManager): self
{
$promise = new self();
$promise = new self($fiberManager);
$promise->exception = $exception;
$promise->resolved = true;
@@ -56,9 +56,9 @@ final class AsyncPromise
/**
* Erstellt Promise aus Callable
*/
public static function create(callable $executor): self
public static function create(callable $executor, FiberManager $fiberManager): self
{
$promise = new self();
$promise = new self($fiberManager);
$promise->fiberManager->async(function () use ($promise, $executor) {
try {
@@ -75,9 +75,9 @@ final class AsyncPromise
/**
* Wartet auf alle Promises
*/
public static function all(array $promises): self
public static function all(array $promises, FiberManager $fiberManager): self
{
$allPromise = new self();
$allPromise = new self($fiberManager);
$allPromise->fiberManager->async(function () use ($allPromise, $promises) {
try {
@@ -101,9 +101,9 @@ final class AsyncPromise
/**
* Wartet auf das erste erfolgreiche Promise
*/
public static function race(array $promises): self
public static function race(array $promises, FiberManager $fiberManager): self
{
$racePromise = new self();
$racePromise = new self($fiberManager);
foreach ($promises as $promise) {
if ($promise instanceof self) {

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Framework\Async;
/**
* Factory für AsyncPromise Instanzen mit proper Dependency Injection
*/
final readonly class AsyncPromiseFactory
{
public function __construct(
private FiberManager $fiberManager
) {
}
/**
* Erstellt ein resolved Promise
*/
public function resolve(mixed $value): AsyncPromise
{
return AsyncPromise::resolve($value, $this->fiberManager);
}
/**
* Erstellt ein rejected Promise
*/
public function reject(\Throwable $exception): AsyncPromise
{
return AsyncPromise::reject($exception, $this->fiberManager);
}
/**
* Erstellt Promise aus Callable
*/
public function create(callable $executor): AsyncPromise
{
return AsyncPromise::create($executor, $this->fiberManager);
}
/**
* Wartet auf alle Promises (variadic)
*/
public function all(AsyncPromise ...$promises): AsyncPromise
{
return AsyncPromise::all($promises, $this->fiberManager);
}
/**
* Wartet auf das erste erfolgreiche Promise (variadic)
*/
public function race(AsyncPromise ...$promises): AsyncPromise
{
return AsyncPromise::race($promises, $this->fiberManager);
}
/**
* Erstellt ein Promise mit direktem FiberManager Zugriff
* Für fortgeschrittene Use-Cases
*/
public function createWithManager(callable $executor): AsyncPromise
{
return new AsyncPromise($this->fiberManager);
}
}

View File

@@ -16,6 +16,7 @@ final readonly class AsyncService
{
public function __construct(
private FiberManager $fiberManager,
private AsyncPromiseFactory $promiseFactory,
private AsyncTimer $asyncTimer,
private Clock $clock,
private Timer $timer
@@ -35,20 +36,20 @@ final readonly class AsyncService
*/
public function promise(callable $operation): AsyncPromise
{
return AsyncPromise::create($operation);
return $this->promiseFactory->create($operation);
}
/**
* Run multiple operations in parallel
* Run multiple operations in parallel (variadic)
*/
public function parallel(array $operations): AsyncPromise
public function parallel(callable ...$operations): AsyncPromise
{
$promises = [];
foreach ($operations as $key => $operation) {
$promises[$key] = $this->promise($operation);
foreach ($operations as $operation) {
$promises[] = $this->promiseFactory->create($operation);
}
return AsyncPromise::all($promises);
return $this->promiseFactory->all(...$promises);
}
/**
@@ -121,9 +122,9 @@ final readonly class AsyncService
}
/**
* Batch operations with concurrency control
* Batch operations with concurrency control (variadic)
*/
public function batch(array $operations, int $maxConcurrency = 10): array
public function batch(int $maxConcurrency = 10, callable ...$operations): array
{
return $this->fiberManager->throttled($operations, $maxConcurrency);
}

View File

@@ -23,8 +23,15 @@ final readonly class AsyncServiceInitializer
public function __invoke(): AsyncService
{
$fiberManager = new FiberManager($this->clock, $this->timer);
$promiseFactory = new AsyncPromiseFactory($fiberManager);
$asyncTimer = new AsyncTimer($fiberManager, $this->clock, $this->timer);
return new AsyncService($fiberManager, $asyncTimer, $this->clock, $this->timer);
return new AsyncService(
$fiberManager,
$promiseFactory,
$asyncTimer,
$this->clock,
$this->timer
);
}
}

View File

@@ -4,112 +4,96 @@ declare(strict_types=1);
namespace App\Framework\Async;
use Fiber;
use Generator;
use Iterator;
/**
* Stream für kontinuierliche asynchrone Datenverarbeitung
* Async Stream für deklarative Stream-Processing-Pipeline
*
* Ermöglicht funktionale Transformationen auf großen Datenmengen
* mit Lazy Evaluation und optionaler paralleler Verarbeitung.
*/
final class AsyncStream
final readonly class AsyncStream
{
/** @var array<callable> */
private array $processors = [];
private bool $closed = false;
public function __construct(
private readonly FiberManager $fiberManager = new FiberManager()
private function __construct(
private Iterator|Generator $source,
private FiberManager $fiberManager
) {
}
/**
* Erstellt einen Stream aus einem Iterator oder Array
*/
public static function from(Iterator|array $source, FiberManager $fiberManager): self
{
$iterator = is_array($source) ? new \ArrayIterator($source) : $source;
return new self($iterator, $fiberManager);
}
/**
* Erstellt einen Stream aus einem Generator
*/
public static function fromGenerator(Generator $generator): self
public static function fromGenerator(callable $generator, FiberManager $fiberManager): self
{
$stream = new self();
return new self($generator(), $fiberManager);
}
$stream->fiberManager->async(function () use ($stream, $generator) {
foreach ($generator as $item) {
if ($stream->closed) {
break;
/**
* Erstellt einen Stream aus einem Range
*/
public static function range(int $start, int $end, FiberManager $fiberManager): self
{
$generator = function () use ($start, $end) {
for ($i = $start; $i <= $end; $i++) {
yield $i;
}
};
return new self($generator(), $fiberManager);
}
/**
* Erstellt einen Stream aus einem AsyncChannel
*/
public static function fromChannel(AsyncChannel $channel, FiberManager $fiberManager): self
{
$generator = function () use ($channel) {
try {
while (true) {
yield $channel->receive();
}
$stream->emit($item);
} catch (ChannelClosedException) {
// Channel geschlossen, Stream endet
}
$stream->close();
});
return $stream;
};
return new self($generator(), $fiberManager);
}
/**
* Erstellt einen Stream aus einem Array
*/
public static function fromArray(array $items): self
{
$stream = new self();
$stream->fiberManager->async(function () use ($stream, $items) {
foreach ($items as $item) {
if ($stream->closed) {
break;
}
$stream->emit($item);
}
$stream->close();
});
return $stream;
}
/**
* Erstellt einen Interval-Stream
*/
public static function interval(float $intervalSeconds, ?int $count = null): self
{
$stream = new self();
$stream->fiberManager->async(function () use ($stream, $intervalSeconds, $count) {
$emitted = 0;
while (! $stream->closed && ($count === null || $emitted < $count)) {
$stream->emit($emitted);
$emitted++;
usleep($intervalSeconds * 1_000_000);
}
$stream->close();
});
return $stream;
}
/**
* Fügt einen Processor zum Stream hinzu
*/
public function pipe(callable $processor): self
{
$this->processors[] = $processor;
return $this;
}
/**
* Filtert Stream-Elemente
*/
public function filter(callable $predicate): self
{
return $this->pipe(function ($item) use ($predicate) {
return $predicate($item) ? $item : null;
});
}
/**
* Transformiert Stream-Elemente
* Wendet eine Transformation auf jedes Element an
*/
public function map(callable $transformer): self
{
return $this->pipe($transformer);
$generator = function () use ($transformer) {
foreach ($this->source as $item) {
yield $transformer($item);
}
};
return new self($generator(), $this->fiberManager);
}
/**
* Filtert Elemente basierend auf einem Prädikat
*/
public function filter(callable $predicate): self
{
$generator = function () use ($predicate) {
foreach ($this->source as $item) {
if ($predicate($item)) {
yield $item;
}
}
};
return new self($generator(), $this->fiberManager);
}
/**
@@ -117,18 +101,17 @@ final class AsyncStream
*/
public function take(int $count): self
{
$taken = 0;
return $this->pipe(function ($item) use (&$taken, $count) {
if ($taken < $count) {
$generator = function () use ($count) {
$taken = 0;
foreach ($this->source as $item) {
if ($taken >= $count) {
break;
}
yield $item;
$taken++;
return $item;
}
$this->close();
return null;
});
};
return new self($generator(), $this->fiberManager);
}
/**
@@ -136,100 +119,243 @@ final class AsyncStream
*/
public function skip(int $count): self
{
$skipped = 0;
$generator = function () use ($count) {
$skipped = 0;
foreach ($this->source as $item) {
if ($skipped < $count) {
$skipped++;
continue;
}
yield $item;
}
};
return new self($generator(), $this->fiberManager);
}
return $this->pipe(function ($item) use (&$skipped, $count) {
if ($skipped < $count) {
$skipped++;
/**
* Teilt Stream in Chunks fester Größe
*/
public function chunk(int $size): self
{
$generator = function () use ($size) {
$chunk = [];
foreach ($this->source as $item) {
$chunk[] = $item;
if (count($chunk) >= $size) {
yield $chunk;
$chunk = [];
}
}
// Letzter Chunk (falls nicht voll)
if (!empty($chunk)) {
yield $chunk;
}
};
return new self($generator(), $this->fiberManager);
}
return null;
/**
* Verarbeitet Stream parallel mit N Workers
*/
public function parallel(int $workers): self
{
$generator = function () use ($workers) {
$pool = new AsyncPool($this->fiberManager, maxConcurrency: $workers);
$results = [];
$index = 0;
foreach ($this->source as $item) {
$currentIndex = $index++;
$pool->add(function () use ($item) {
return $item;
}, (string) $currentIndex);
}
return $item;
});
}
$processedResults = $pool->execute();
/**
* Sammelt alle Stream-Elemente in einem Array
*/
public function collect(): Fiber
{
return $this->fiberManager->async(function () {
$collected = [];
// Sortiere Ergebnisse nach Original-Reihenfolge
ksort($processedResults);
$this->subscribe(function ($item) use (&$collected) {
if ($item !== null) {
$collected[] = $item;
}
});
return $collected;
});
}
/**
* Reduziert den Stream zu einem einzelnen Wert
*/
public function reduce(callable $reducer, mixed $initial = null): Fiber
{
return $this->fiberManager->async(function () use ($reducer, $initial) {
$accumulator = $initial;
$this->subscribe(function ($item) use (&$accumulator, $reducer) {
if ($item !== null) {
$accumulator = $reducer($accumulator, $item);
}
});
return $accumulator;
});
}
/**
* Abonniert den Stream
*/
public function subscribe(callable $subscriber): void
{
$this->processors[] = $subscriber;
}
/**
* Emittiert ein Element an alle Subscriber
*/
private function emit(mixed $item): void
{
foreach ($this->processors as $processor) {
$result = $processor($item);
if ($result !== null) {
$item = $result;
foreach ($processedResults as $result) {
yield $result;
}
};
return new self($generator(), $this->fiberManager);
}
/**
* Flat-mapped nested structures
*/
public function flatMap(callable $transformer): self
{
$generator = function () use ($transformer) {
foreach ($this->source as $item) {
$transformed = $transformer($item);
if (is_iterable($transformed)) {
foreach ($transformed as $subItem) {
yield $subItem;
}
} else {
yield $transformed;
}
}
};
return new self($generator(), $this->fiberManager);
}
/**
* Führt eine Aktion für jedes Element aus (Terminal Operation)
*/
public function forEach(callable $action): void
{
foreach ($this->source as $item) {
$action($item);
}
}
/**
* Schließt den Stream
* Reduziert den Stream auf einen einzelnen Wert (Terminal Operation)
*/
public function close(): void
public function reduce(callable $reducer, mixed $initial = null): mixed
{
$this->closed = true;
$accumulator = $initial;
foreach ($this->source as $item) {
$accumulator = $reducer($accumulator, $item);
}
return $accumulator;
}
/**
* Prüft ob Stream geschlossen ist
* Sammelt alle Elemente in ein Array (Terminal Operation)
*/
public function isClosed(): bool
public function toArray(): array
{
return $this->closed;
return iterator_to_array($this->source, false);
}
/**
* Gibt Stream-Statistiken zurück
* Zählt die Anzahl der Elemente (Terminal Operation)
*/
public function getStats(): array
public function count(): int
{
return [
'processors' => count($this->processors),
'closed' => $this->closed,
];
$count = 0;
foreach ($this->source as $_) {
$count++;
}
return $count;
}
/**
* Prüft ob mindestens ein Element das Prädikat erfüllt (Terminal Operation)
*/
public function any(callable $predicate): bool
{
foreach ($this->source as $item) {
if ($predicate($item)) {
return true;
}
}
return false;
}
/**
* Prüft ob alle Elemente das Prädikat erfüllen (Terminal Operation)
*/
public function all(callable $predicate): bool
{
foreach ($this->source as $item) {
if (!$predicate($item)) {
return false;
}
}
return true;
}
/**
* Findet das erste Element das Prädikat erfüllt (Terminal Operation)
*/
public function first(?callable $predicate = null): mixed
{
foreach ($this->source as $item) {
if ($predicate === null || $predicate($item)) {
return $item;
}
}
return null;
}
/**
* Sendet alle Elemente in einen AsyncChannel (Terminal Operation)
*/
public function toChannel(AsyncChannel $channel): void
{
foreach ($this->source as $item) {
$channel->send($item);
}
$channel->close();
}
/**
* Gruppiert Elemente nach Key-Funktion (Terminal Operation)
*/
public function groupBy(callable $keySelector): array
{
$groups = [];
foreach ($this->source as $item) {
$key = $keySelector($item);
if (!isset($groups[$key])) {
$groups[$key] = [];
}
$groups[$key][] = $item;
}
return $groups;
}
/**
* Sortiert Stream (Terminal Operation, lädt alle Elemente in Memory)
*/
public function sorted(?callable $comparator = null): self
{
$items = $this->toArray();
if ($comparator !== null) {
usort($items, $comparator);
} else {
sort($items);
}
return self::from($items, $this->fiberManager);
}
/**
* Entfernt Duplikate basierend auf Wert oder Key-Funktion
*/
public function distinct(?callable $keySelector = null): self
{
$generator = function () use ($keySelector) {
$seen = [];
foreach ($this->source as $item) {
$key = $keySelector !== null ? $keySelector($item) : $item;
$keyString = is_scalar($key) ? (string) $key : serialize($key);
if (!isset($seen[$keyString])) {
$seen[$keyString] = true;
yield $item;
}
}
};
return new self($generator(), $this->fiberManager);
}
/**
* Führt eine Aktion für jedes Element aus ohne Stream zu ändern
*/
public function tap(callable $action): self
{
$generator = function () use ($action) {
foreach ($this->source as $item) {
$action($item);
yield $item;
}
};
return new self($generator(), $this->fiberManager);
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Framework\Async;
use RuntimeException;
/**
* Exception für geschlossene Channels
*/
final class ChannelClosedException extends RuntimeException
{
}

View File

@@ -44,9 +44,9 @@ final class FiberManager
/**
* Führt eine asynchrone Operation aus und gibt sofort einen Fiber zurück
*/
public function async(callable $operation, ?string $operationId = null): Fiber
public function async(callable $operation, string|int|null $operationId = null): Fiber
{
$operationId ??= uniqid('fiber_', true);
$operationId = $operationId !== null ? (string) $operationId : uniqid('fiber_', true);
$startTime = $this->clock->time();
$fiber = new Fiber(function () use ($operation, $operationId, $startTime) {
@@ -183,6 +183,9 @@ final class FiberManager
/**
* Execute operation with timeout using Duration
*
* Note: This only works reliably with cooperative operations that use Fiber::suspend()
* For blocking operations, the timeout check cannot interrupt execution.
*/
public function withTimeoutDuration(callable $operation, Duration $timeout): mixed
{
@@ -206,6 +209,90 @@ final class FiberManager
return $fiber->getReturn();
}
/**
* Execute cooperative operation with timeout support
*
* The operation can use Fiber::suspend() to yield control, allowing timeout checks.
* This enables proper timeout handling for long-running operations.
*/
public function asyncCooperative(callable $operation, string|int|null $operationId = null): Fiber
{
$operationId = $operationId !== null ? (string) $operationId : uniqid('fiber_', true);
$startTime = $this->clock->time();
$fiber = new Fiber(function () use ($operation, $operationId, $startTime) {
$this->fiberStartTimes[$operationId] = $startTime;
try {
$result = $operation();
$this->fiberResults[$operationId] = $result;
$this->fiberEndTimes[$operationId] = $this->clock->time();
return $result;
} catch (Throwable $e) {
$this->fiberErrors[$operationId] = $e;
$this->fiberEndTimes[$operationId] = $this->clock->time();
throw $e;
} finally {
unset($this->runningFibers[$operationId]);
}
});
$this->runningFibers[$operationId] = $fiber;
// Don't start immediately - let caller control execution
return $fiber;
}
/**
* Execute cooperative operation with timeout using Duration
*
* The operation must use Fiber::suspend() to allow timeout checks.
* Example:
*
* $result = $manager->withTimeoutCooperative(function() {
* for ($i = 0; $i < 100; $i++) {
* doWork($i);
* if ($i % 10 === 0) {
* Fiber::suspend(); // Allow timeout check
* }
* }
* return 'done';
* }, Duration::fromSeconds(5));
*/
public function withTimeoutCooperative(callable $operation, Duration $timeout): mixed
{
$startTime = $this->clock->time();
$endTime = Timestamp::fromFloat($startTime->toFloat() + $timeout->toSeconds());
$fiber = $this->asyncCooperative($operation);
// Start fiber
$fiber->start();
// Process fiber with timeout checks
while (! $fiber->isTerminated()) {
// Check timeout
if ($this->clock->time()->isAfter($endTime)) {
$elapsed = $startTime->age($this->clock);
throw new AsyncTimeoutException(
"Operation exceeded timeout of {$timeout->toHumanReadable()} (elapsed: {$elapsed->toHumanReadable()})"
);
}
// Resume suspended fiber
if ($fiber->isSuspended()) {
$fiber->resume();
}
// Small delay to prevent CPU spinning
$this->timer->sleep(Duration::fromMilliseconds(1));
}
return $fiber->getReturn();
}
/**
* Kombiniert mehrere Fibers zu einem einzigen
*/

View File

@@ -4,17 +4,15 @@ declare(strict_types=1);
namespace App\Framework\Attributes;
use App\Framework\Core\StaticRoute;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\MethodName;
use App\Framework\Http\Method;
use App\Framework\Router\RouteNameInterface;
use App\Framework\Router\ValueObjects\ParameterCollection;
use App\Framework\Router\ValueObjects\RoutePath;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final readonly class Route
final readonly class Route implements RouteAttribute
{
public function __construct(
public string|RoutePath $path,

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Framework\Attributes;
use App\Framework\Http\Method;
use App\Framework\Router\ValueObjects\RoutePath;
interface RouteAttribute
{
public string|RoutePath $path {get;}
public Method $method {get;}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Framework\Audit;
use App\Framework\Audit\ValueObjects\AuditEntry;
use App\Framework\Audit\ValueObjects\AuditId;
use App\Framework\Audit\ValueObjects\AuditQuery;
/**
* Audit logger interface
*/
interface AuditLogger
{
/**
* Log an audit entry
*/
public function log(AuditEntry $entry): void;
/**
* Retrieve an audit entry by ID
*/
public function find(AuditId $id): ?AuditEntry;
/**
* Query audit entries with type-safe filters
*
* @return array<AuditEntry>
*/
public function query(AuditQuery $query): array;
/**
* Count audit entries matching query
*/
public function count(AuditQuery $query): int;
/**
* Delete audit entries older than specified date
*/
public function purgeOlderThan(\DateTimeImmutable $date): int;
}

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Framework\Audit;
use App\Framework\Audit\ValueObjects\AuditEntry;
use App\Framework\Audit\ValueObjects\AuditId;
use App\Framework\Audit\ValueObjects\AuditQuery;
use DateTimeImmutable;
/**
* In-memory audit logger for testing
*/
final class InMemoryAuditLogger implements AuditLogger
{
/**
* @var array<string, AuditEntry>
*/
private array $entries = [];
public function log(AuditEntry $entry): void
{
$this->entries[$entry->id->toString()] = $entry;
}
public function find(AuditId $id): ?AuditEntry
{
return $this->entries[$id->toString()] ?? null;
}
/**
* @return array<AuditEntry>
*/
public function query(AuditQuery $query): array
{
$results = array_values($this->entries);
// Apply filters
if ($query->action !== null) {
$results = array_filter(
$results,
fn(AuditEntry $entry) => $entry->action === $query->action
);
}
if ($query->entityType !== null) {
$results = array_filter(
$results,
fn(AuditEntry $entry) => $entry->entityType === $query->entityType
);
}
if ($query->entityId !== null) {
$results = array_filter(
$results,
fn(AuditEntry $entry) => $entry->entityId === $query->entityId
);
}
if ($query->userId !== null) {
$results = array_filter(
$results,
fn(AuditEntry $entry) => $entry->userId === $query->userId
);
}
if ($query->success !== null) {
$results = array_filter(
$results,
fn(AuditEntry $entry) => $entry->success === $query->success
);
}
if ($query->startDate !== null) {
$results = array_filter(
$results,
fn(AuditEntry $entry) => $entry->timestamp >= $query->startDate
);
}
if ($query->endDate !== null) {
$results = array_filter(
$results,
fn(AuditEntry $entry) => $entry->timestamp <= $query->endDate
);
}
// Sort by timestamp descending (newest first)
usort($results, fn(AuditEntry $a, AuditEntry $b) =>
$b->timestamp <=> $a->timestamp
);
// Apply pagination
return array_slice($results, $query->offset, $query->limit);
}
public function count(AuditQuery $query): int
{
// Reuse query logic but count instead
$queryWithoutPagination = $query->withLimit(PHP_INT_MAX)->withOffset(0);
return count($this->query($queryWithoutPagination));
}
public function purgeOlderThan(DateTimeImmutable $date): int
{
$count = 0;
foreach ($this->entries as $id => $entry) {
if ($entry->timestamp < $date) {
unset($this->entries[$id]);
$count++;
}
}
return $count;
}
/**
* Clear all entries (for testing)
*/
public function clear(): void
{
$this->entries = [];
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Framework\Audit\ValueObjects;
use App\Framework\DateTime\Clock;
use App\Framework\Http\IpAddress;
use App\Framework\UserAgent\UserAgent;
use DateTimeImmutable;
/**
* Audit trail entry value object
*/
final readonly class AuditEntry
{
/**
* @param array<string, mixed> $metadata
*/
public function __construct(
public AuditId $id,
public AuditableAction $action,
public string $entityType,
public ?string $entityId,
public DateTimeImmutable $timestamp,
public ?string $userId = null,
public ?IpAddress $ipAddress = null,
public ?UserAgent $userAgent = null,
public array $metadata = [],
public bool $success = true,
public ?string $errorMessage = null
) {
}
public static function create(
Clock $clock,
AuditableAction $action,
string $entityType,
?string $entityId = null,
?string $userId = null,
?IpAddress $ipAddress = null,
?UserAgent $userAgent = null,
array $metadata = []
): self {
return new self(
id: AuditId::generate($clock),
action: $action,
entityType: $entityType,
entityId: $entityId,
timestamp: new DateTimeImmutable(),
userId: $userId,
ipAddress: $ipAddress,
userAgent: $userAgent,
metadata: $metadata,
success: true
);
}
public static function failed(
Clock $clock,
AuditableAction $action,
string $entityType,
?string $entityId = null,
string $errorMessage = '',
?string $userId = null,
?IpAddress $ipAddress = null,
?UserAgent $userAgent = null,
array $metadata = []
): self {
return new self(
id: AuditId::generate($clock),
action: $action,
entityType: $entityType,
entityId: $entityId,
timestamp: new DateTimeImmutable(),
userId: $userId,
ipAddress: $ipAddress,
userAgent: $userAgent,
metadata: $metadata,
success: false,
errorMessage: $errorMessage
);
}
public function toArray(): array
{
return [
'id' => $this->id->toString(),
'action' => $this->action->value,
'entity_type' => $this->entityType,
'entity_id' => $this->entityId,
'timestamp' => $this->timestamp->format('Y-m-d H:i:s'),
'user_id' => $this->userId,
'ip_address' => $this->ipAddress !== null ? (string) $this->ipAddress : null,
'user_agent' => $this->userAgent?->value,
'metadata' => $this->metadata,
'success' => $this->success,
'error_message' => $this->errorMessage,
];
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Framework\Audit\ValueObjects;
use App\Framework\DateTime\Clock;
use App\Framework\Ulid\Ulid;
/**
* Audit Entry ID value object (ULID-based)
*/
final readonly class AuditId
{
private Ulid $ulid;
public function __construct(
Clock $clock,
?string $value = null
) {
$this->ulid = $value !== null
? Ulid::fromString($clock, $value)
: new Ulid($clock);
}
public static function generate(Clock $clock): self
{
return new self($clock);
}
public static function fromString(Clock $clock, string $value): self
{
return new self($clock, $value);
}
public function toString(): string
{
return (string) $this->ulid;
}
public function equals(self $other): bool
{
return $this->toString() === $other->toString();
}
public function __toString(): string
{
return $this->toString();
}
}

View File

@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace App\Framework\Audit\ValueObjects;
use DateTimeImmutable;
/**
* Audit query filter value object
*/
final readonly class AuditQuery
{
public function __construct(
public ?AuditableAction $action = null,
public ?string $entityType = null,
public ?string $entityId = null,
public ?string $userId = null,
public ?bool $success = null,
public ?DateTimeImmutable $startDate = null,
public ?DateTimeImmutable $endDate = null,
public int $limit = 100,
public int $offset = 0
) {
}
public static function all(): self
{
return new self();
}
public static function forAction(AuditableAction $action): self
{
return new self(action: $action);
}
public static function forEntity(string $entityType, ?string $entityId = null): self
{
return new self(entityType: $entityType, entityId: $entityId);
}
public static function forUser(string $userId): self
{
return new self(userId: $userId);
}
public static function failedOnly(): self
{
return new self(success: false);
}
public static function successfulOnly(): self
{
return new self(success: true);
}
public static function inDateRange(DateTimeImmutable $start, DateTimeImmutable $end): self
{
return new self(startDate: $start, endDate: $end);
}
public function withAction(AuditableAction $action): self
{
return new self(
action: $action,
entityType: $this->entityType,
entityId: $this->entityId,
userId: $this->userId,
success: $this->success,
startDate: $this->startDate,
endDate: $this->endDate,
limit: $this->limit,
offset: $this->offset
);
}
public function withEntityType(string $entityType): self
{
return new self(
action: $this->action,
entityType: $entityType,
entityId: $this->entityId,
userId: $this->userId,
success: $this->success,
startDate: $this->startDate,
endDate: $this->endDate,
limit: $this->limit,
offset: $this->offset
);
}
public function withEntityId(string $entityId): self
{
return new self(
action: $this->action,
entityType: $this->entityType,
entityId: $entityId,
userId: $this->userId,
success: $this->success,
startDate: $this->startDate,
endDate: $this->endDate,
limit: $this->limit,
offset: $this->offset
);
}
public function withUserId(string $userId): self
{
return new self(
action: $this->action,
entityType: $this->entityType,
entityId: $this->entityId,
userId: $userId,
success: $this->success,
startDate: $this->startDate,
endDate: $this->endDate,
limit: $this->limit,
offset: $this->offset
);
}
public function withSuccess(bool $success): self
{
return new self(
action: $this->action,
entityType: $this->entityType,
entityId: $this->entityId,
userId: $this->userId,
success: $success,
startDate: $this->startDate,
endDate: $this->endDate,
limit: $this->limit,
offset: $this->offset
);
}
public function withDateRange(DateTimeImmutable $start, DateTimeImmutable $end): self
{
return new self(
action: $this->action,
entityType: $this->entityType,
entityId: $this->entityId,
userId: $this->userId,
success: $this->success,
startDate: $start,
endDate: $end,
limit: $this->limit,
offset: $this->offset
);
}
public function withLimit(int $limit): self
{
return new self(
action: $this->action,
entityType: $this->entityType,
entityId: $this->entityId,
userId: $this->userId,
success: $this->success,
startDate: $this->startDate,
endDate: $this->endDate,
limit: $limit,
offset: $this->offset
);
}
public function withOffset(int $offset): self
{
return new self(
action: $this->action,
entityType: $this->entityType,
entityId: $this->entityId,
userId: $this->userId,
success: $this->success,
startDate: $this->startDate,
endDate: $this->endDate,
limit: $this->limit,
offset: $offset
);
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Framework\Audit\ValueObjects;
/**
* Auditable action types
*/
enum AuditableAction: string
{
// Authentication & Authorization
case LOGIN = 'auth.login';
case LOGOUT = 'auth.logout';
case LOGIN_FAILED = 'auth.login_failed';
case PASSWORD_CHANGED = 'auth.password_changed';
case PASSWORD_RESET = 'auth.password_reset';
case PERMISSION_GRANTED = 'authz.permission_granted';
case PERMISSION_DENIED = 'authz.permission_denied';
// CRUD Operations
case CREATE = 'crud.create';
case READ = 'crud.read';
case UPDATE = 'crud.update';
case DELETE = 'crud.delete';
// Data Operations
case EXPORT = 'data.export';
case IMPORT = 'data.import';
case DOWNLOAD = 'data.download';
// System Operations
case CONFIG_CHANGED = 'system.config_changed';
case FEATURE_ENABLED = 'system.feature_enabled';
case FEATURE_DISABLED = 'system.feature_disabled';
// Security Events
case SECURITY_VIOLATION = 'security.violation';
case SUSPICIOUS_ACTIVITY = 'security.suspicious_activity';
case ACCESS_DENIED = 'security.access_denied';
// Custom
case CUSTOM = 'custom';
}

View File

@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
namespace App\Framework\Auth;
use App\Framework\Auth\Attributes\IpAuth;
use App\Framework\Auth\ValueObjects\IpAuthPolicy;
use App\Framework\Auth\ValueObjects\NamespaceAccessPolicy;
use App\Framework\Config\TypedConfiguration;
use App\Framework\Http\IpAddress;
use App\Framework\Http\Request;
use App\Framework\Router\Exception\RouteNotFound;
use App\Framework\Router\RouteContext;
/**
* Service for route-based authorization checks
*
* Handles:
* - Legacy Auth attribute checks
* - Namespace-based access blocking (with allowlist)
* - Namespace-based IP restrictions
* - Route-specific IP auth attributes
*/
final readonly class RouteAuthorizationService
{
/**
* @param TypedConfiguration $config
* @param array<string, array{visibility?: string, access_policy?: NamespaceAccessPolicy}> $namespaceConfig
*/
public function __construct(
private TypedConfiguration $config,
private array $namespaceConfig = []
) {
}
/**
* Authorize access to a route
*
* @throws RouteNotFound If authorization fails (hides route existence)
*/
public function authorize(Request $request, RouteContext $routeContext): void
{
$clientIp = $this->getClientIp($request);
$controllerClass = $routeContext->match->route->controller;
// 1. Check legacy Auth attribute (backward compatibility)
$this->checkLegacyAuthAttribute($clientIp, $routeContext);
// 2. Check namespace-based access blocking
$this->checkNamespaceAccessPolicy($controllerClass, $routeContext->path);
// 3. Check namespace-based IP restrictions
$this->checkNamespaceIpRestrictions($clientIp, $controllerClass, $routeContext->path);
// 4. Check route-specific IP auth attributes
$this->checkRouteIpAuthAttribute($clientIp, $routeContext);
}
/**
* Check legacy Auth attribute (backward compatibility)
*/
private function checkLegacyAuthAttribute(IpAddress $clientIp, RouteContext $routeContext): void
{
if ($this->config->app->debug) {
return; // Skip in debug mode
}
if (! in_array(Auth::class, $routeContext->match->route->attributes)) {
return; // No Auth attribute
}
$wireguardIp = '172.20.0.1';
if ($clientIp->value !== $wireguardIp) {
throw new RouteNotFound($routeContext->path);
}
}
/**
* Check namespace-based access blocking
*/
private function checkNamespaceAccessPolicy(string $controllerClass, string $path): void
{
$namespace = $this->extractNamespace($controllerClass);
foreach ($this->namespaceConfig as $pattern => $config) {
if (! $this->namespaceMatches($namespace, $pattern)) {
continue;
}
// Check if config has access_policy
$accessPolicy = $config['access_policy'] ?? null;
if (! $accessPolicy instanceof NamespaceAccessPolicy) {
continue;
}
if ($accessPolicy->isControllerBlocked($controllerClass)) {
throw new RouteNotFound($path);
}
}
}
/**
* Check namespace-based IP restrictions
*/
private function checkNamespaceIpRestrictions(
IpAddress $clientIp,
string $controllerClass,
string $path
): void {
$namespacePolicy = $this->getNamespaceIpPolicy($controllerClass);
if ($namespacePolicy && ! $namespacePolicy->isAllowed($clientIp)) {
throw new RouteNotFound($path);
}
}
/**
* Check route-specific IP auth attributes
*/
private function checkRouteIpAuthAttribute(IpAddress $clientIp, RouteContext $routeContext): void
{
$ipAuthAttribute = $this->getIpAuthAttribute($routeContext->match->route);
if (! $ipAuthAttribute) {
return;
}
$routePolicy = $ipAuthAttribute->createPolicy();
if (! $routePolicy->isAllowed($clientIp)) {
throw new RouteNotFound($routeContext->path);
}
}
/**
* Get client IP address from request
*/
private function getClientIp(Request $request): IpAddress
{
return $request->server->getClientIp() ?? IpAddress::localhost();
}
/**
* Get namespace-based IP policy
*/
private function getNamespaceIpPolicy(string $controllerClass): ?IpAuthPolicy
{
$namespace = $this->extractNamespace($controllerClass);
foreach ($this->namespaceConfig as $pattern => $config) {
if ($this->namespaceMatches($namespace, $pattern)) {
return $this->createIpPolicyFromConfig($config);
}
}
return null;
}
/**
* Extract namespace from class name
*/
private function extractNamespace(string $className): string
{
$parts = explode('\\', $className);
array_pop($parts); // Remove class name
return implode('\\', $parts);
}
/**
* Check if namespace matches pattern
*/
private function namespaceMatches(string $namespace, string $pattern): bool
{
// Exact match
if ($namespace === $pattern) {
return true;
}
// Wildcard pattern (e.g., "App\Admin\*")
if (str_ends_with($pattern, '*')) {
$prefix = rtrim($pattern, '*');
return str_starts_with($namespace, $prefix);
}
// Prefix match
return str_starts_with($namespace, $pattern);
}
/**
* Create IP policy from configuration
*/
private function createIpPolicyFromConfig(array $config): IpAuthPolicy
{
$visibility = $config['visibility'] ?? 'public';
return match ($visibility) {
'admin' => IpAuthPolicy::adminOnly(),
'local' => IpAuthPolicy::localOnly(),
'development' => IpAuthPolicy::development(),
'private' => IpAuthPolicy::localOnly(),
'custom' => IpAuthPolicy::fromConfig($config),
default => IpAuthPolicy::fromConfig([]) // No restrictions for 'public'
};
}
/**
* Get IpAuth attribute from route
*/
private function getIpAuthAttribute($route): ?IpAuth
{
// Check if route has IpAuth attribute in its attributes array
foreach ($route->attributes ?? [] as $attribute) {
if ($attribute instanceof IpAuth) {
return $attribute;
}
}
// Also check via reflection for method-level attributes
try {
$reflection = new \ReflectionMethod($route->controller, $route->action);
$attributes = $reflection->getAttributes(IpAuth::class);
if (! empty($attributes)) {
return $attributes[0]->newInstance();
}
// Check controller class for IpAuth attribute
$classReflection = new \ReflectionClass($route->controller);
$classAttributes = $classReflection->getAttributes(IpAuth::class);
if (! empty($classAttributes)) {
return $classAttributes[0]->newInstance();
}
} catch (\ReflectionException $e) {
// Ignore reflection errors
}
return null;
}
/**
* Create service with namespace configuration
*/
public static function withNamespaceConfig(
TypedConfiguration $config,
array $namespaceConfig
): self {
return new self($config, $namespaceConfig);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Framework\Auth;
use App\Framework\Auth\ValueObjects\NamespaceAccessPolicy;
use App\Framework\Config\TypedConfiguration;
use App\Framework\DI\Attributes\Initializer;
use App\Framework\DI\Container;
final readonly class RouteAuthorizationServiceInitializer
{
public function __construct(
private TypedConfiguration $config
) {
}
#[Initializer]
public function __invoke(Container $container): RouteAuthorizationService
{
// Configure namespace-based access control
$namespaceConfig = [
// Example: Block all Admin controllers by default
// 'App\Application\Admin\*' => [
// 'visibility' => 'admin', // IP-based restriction
// 'access_policy' => NamespaceAccessPolicy::blocked() // Block all
// ],
// Example: Block Admin except specific controllers
// 'App\Application\Admin\*' => [
// 'visibility' => 'public',
// 'access_policy' => NamespaceAccessPolicy::blockedExcept(
// \App\Application\Admin\LoginController::class,
// \App\Application\Admin\HealthCheckController::class
// )
// ],
// Example: IP restriction only (no namespace blocking)
// 'App\Application\Admin\*' => [
// 'visibility' => 'admin', // Only admin IPs allowed
// ],
];
return new RouteAuthorizationService(
config: $this->config,
namespaceConfig: $namespaceConfig
);
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Framework\Auth\ValueObjects;
/**
* Value Object for namespace-based access control
*
* Allows blocking entire controller namespaces (e.g., App\Application\Admin\*)
* with optional allowlist for specific controllers.
*/
final readonly class NamespaceAccessPolicy
{
/**
* @param bool $isBlocked Whether this namespace is blocked
* @param array<string> $allowedControllers Specific controller class names to allow despite block
*/
public function __construct(
private bool $isBlocked = false,
private array $allowedControllers = []
) {
}
/**
* Block all controllers in this namespace
*/
public static function blocked(): self
{
return new self(isBlocked: true);
}
/**
* Block namespace but allow specific controllers
*
* @param string ...$controllerClasses Full controller class names
*/
public static function blockedExcept(string ...$controllerClasses): self
{
return new self(
isBlocked: true,
allowedControllers: $controllerClasses
);
}
/**
* Allow all controllers (no blocking)
*/
public static function allowed(): self
{
return new self(isBlocked: false);
}
/**
* Check if a specific controller is blocked
*
* @param string $controllerClass Full controller class name
*/
public function isControllerBlocked(string $controllerClass): bool
{
if (! $this->isBlocked) {
return false;
}
// Check if controller is in allowlist
return ! in_array($controllerClass, $this->allowedControllers, true);
}
/**
* Add controllers to allowlist
*/
public function withAllowedControllers(string ...$controllerClasses): self
{
return new self(
isBlocked: $this->isBlocked,
allowedControllers: array_unique([...$this->allowedControllers, ...$controllerClasses])
);
}
/**
* Check if namespace has any restrictions
*/
public function hasRestrictions(): bool
{
return $this->isBlocked;
}
}

View File

@@ -1,80 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Framework\BuildTime\Discovery\Commands;
use App\Framework\BuildTime\Discovery\Scanners\AttributeScanner;
use App\Framework\BuildTime\Discovery\Scanners\InterfaceScanner;
use App\Framework\BuildTime\Discovery\Scanners\TemplateScanner;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Core\PathProvider;
use App\Framework\Discovery\Storage\DiscoveryStorageService;
/**
* Bootstrap command that runs all discovery scanners
*
* This command doesn't rely on discovery itself - it's manually registered
*/
#[ConsoleCommand(
name: 'discovery:bootstrap',
description: 'Run all discovery scanners and build cache'
)]
final readonly class BootstrapDiscoveryCommand
{
public function __construct(
private AttributeScanner $attributeScanner,
private TemplateScanner $templateScanner,
private InterfaceScanner $interfaceScanner,
private DiscoveryStorageService $storage,
private PathProvider $pathProvider,
private ConsoleOutput $output
) {
}
public function execute(ConsoleInput $input): int
{
$this->output->info('Bootstrapping discovery system...');
$totalStart = microtime(true);
// 1. Discover Attributes
$this->output->writeln("\n📦 Discovering attributes...");
$attrStart = microtime(true);
$paths = [$this->pathProvider->getSourcePath()];
$attributeRegistry = $this->attributeScanner->scan($paths);
$this->storage->storeAttributes($attributeRegistry);
$attrDuration = round((microtime(true) - $attrStart) * 1000, 2);
$this->output->success("{$attributeRegistry->count()} attributes in {$attrDuration}ms");
// 2. Discover Templates
$this->output->writeln("\n📄 Discovering templates...");
$tplStart = microtime(true);
$templatePaths = [
$this->pathProvider->getSourcePath(),
$this->pathProvider->getBasePath() . '/resources'
];
$templateRegistry = $this->templateScanner->scan($templatePaths);
$this->storage->storeTemplates($templateRegistry);
$tplDuration = round((microtime(true) - $tplStart) * 1000, 2);
$this->output->success("" . count($templateRegistry->getAll()) . " templates in {$tplDuration}ms");
// 3. Discover Interfaces
$this->output->writeln("\n🔌 Discovering interface implementations...");
$intStart = microtime(true);
$interfaceRegistry = $this->interfaceScanner->scan($paths);
$this->storage->storeInterfaces($interfaceRegistry);
$intDuration = round((microtime(true) - $intStart) * 1000, 2);
$this->output->success("{$interfaceRegistry->count()} implementations in {$intDuration}ms");
// Summary
$totalDuration = round((microtime(true) - $totalStart) * 1000, 2);
$this->output->writeln("\n" . str_repeat("=", 60));
$this->output->success("🎉 Discovery bootstrap complete in {$totalDuration}ms");
$this->output->writeln(" 📁 Stored in: storage/discovery/");
$this->output->writeln(str_repeat("=", 60));
return 0;
}
}

View File

@@ -1,52 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Framework\BuildTime\Discovery\Commands;
use App\Framework\BuildTime\Discovery\Scanners\AttributeScanner;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Core\PathProvider;
use App\Framework\Discovery\Storage\DiscoveryStorageService;
/**
* Discover all attributes and store them for runtime use
*/
#[ConsoleCommand(
name: 'discovery:attributes',
description: 'Discover and cache all PHP attributes (Routes, Initializers, etc.)'
)]
final readonly class DiscoverAttributesCommand
{
public function __construct(
private AttributeScanner $scanner,
private DiscoveryStorageService $storage,
private PathProvider $pathProvider,
private ConsoleOutput $output
) {
}
public function execute(ConsoleInput $input): int
{
$this->output->info('Discovering attributes...');
$startTime = microtime(true);
// Scan src directory for attributes
$paths = [$this->pathProvider->getSourcePath()];
$registry = $this->scanner->scan($paths);
// Store for runtime
$this->storage->storeAttributes($registry);
$duration = round((microtime(true) - $startTime) * 1000, 2);
$count = $registry->count();
$this->output->success("Discovered {$count} attributes in {$duration}ms");
$this->output->writeln(' Stored in: storage/discovery/attributes.php');
return 0;
}
}

View File

@@ -1,64 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Framework\BuildTime\Discovery\Commands;
use App\Framework\BuildTime\Discovery\Scanners\InterfaceScanner;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Core\PathProvider;
use App\Framework\Discovery\Storage\DiscoveryStorageService;
/**
* Discover all interface implementations and store them for runtime use
*/
#[ConsoleCommand(
name: 'discovery:interfaces',
description: 'Discover and cache all interface implementations'
)]
final readonly class DiscoverInterfacesCommand
{
public function __construct(
private InterfaceScanner $scanner,
private DiscoveryStorageService $storage,
private PathProvider $pathProvider,
private ConsoleOutput $output
) {
}
public function execute(ConsoleInput $input): int
{
$this->output->info('Discovering interface implementations...');
$startTime = microtime(true);
// Scan src directory for interface implementations
$paths = [$this->pathProvider->getSourcePath()];
$registry = $this->scanner->scan($paths);
// Store for runtime
$this->storage->storeInterfaces($registry);
$duration = round((microtime(true) - $startTime) * 1000, 2);
$count = $registry->getTotalMappings();
$this->output->success("Discovered {$count} interface implementations in {$duration}ms");
$this->output->writeln(' Stored in: storage/discovery/interfaces.php');
// Show sample interface mappings
$this->output->writeln("\nSample interface implementations:");
$all = $registry->getAll();
$sample = array_slice($all, 0, 3);
foreach ($sample as $interfaceName => $implementations) {
$this->output->writeln(" {$interfaceName}:");
foreach (array_slice($implementations, 0, 2) as $impl) {
$this->output->writeln(" - {$impl}");
}
}
return 0;
}
}

View File

@@ -1,63 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Framework\BuildTime\Discovery\Commands;
use App\Framework\BuildTime\Discovery\Scanners\TemplateScanner;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Core\PathProvider;
use App\Framework\Discovery\Storage\DiscoveryStorageService;
/**
* Discover all templates and store them for runtime use
*/
#[ConsoleCommand(
name: 'discovery:templates',
description: 'Discover and cache all .view.php templates'
)]
final readonly class DiscoverTemplatesCommand
{
public function __construct(
private TemplateScanner $scanner,
private DiscoveryStorageService $storage,
private PathProvider $pathProvider,
private ConsoleOutput $output
) {
}
public function execute(ConsoleInput $input): int
{
$this->output->info('Discovering templates...');
$startTime = microtime(true);
// Scan src and resources directories for templates
$paths = [
$this->pathProvider->getSourcePath(),
$this->pathProvider->getBasePath() . '/resources'
];
$registry = $this->scanner->scan($paths);
// Store for runtime
$this->storage->storeTemplates($registry);
$duration = round((microtime(true) - $startTime) * 1000, 2);
$count = count($registry->getAll());
$this->output->success("Discovered {$count} templates in {$duration}ms");
$this->output->writeln(' Stored in: storage/discovery/templates.php');
// Show sample templates
$this->output->writeln("\nSample templates:");
$templates = array_slice($registry->getAll(), 0, 5);
foreach ($templates as $template) {
$this->output->writeln(" - {$template->name} ({$template->path})");
}
return 0;
}
}

View File

@@ -1,201 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Framework\BuildTime\Discovery\Scanners;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\MethodName;
use App\Framework\Discovery\Results\AttributeRegistry;
use App\Framework\Discovery\ValueObjects\AttributeTarget;
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
use App\Framework\Filesystem\FilePath;
use App\Framework\Filesystem\FileScanner;
use App\Framework\Filesystem\ValueObjects\FilePattern;
use App\Framework\Reflection\ReflectionProvider;
use App\Framework\Reflection\WrappedReflectionAttribute;
/**
* Specialized scanner for PHP attributes
*
* Scans PHP files for classes with attributes like:
* - #[Route] - HTTP routes
* - #[Initializer] - DI initializers
* - #[ConsoleCommand] - CLI commands
* - #[McpTool] / #[McpResource] - MCP integration
*/
final readonly class AttributeScanner
{
public function __construct(
private FileScanner $fileScanner,
private ReflectionProvider $reflectionProvider
) {
}
/**
* Scan directories for PHP attributes
*
* @param array<string> $paths
*/
public function scan(array $paths): AttributeRegistry
{
$registry = new AttributeRegistry();
foreach ($paths as $path) {
$this->scanDirectory($path, $registry);
}
return $registry;
}
/**
* Scan single directory for attributes
*/
private function scanDirectory(string $path, AttributeRegistry $registry): void
{
$filePath = FilePath::create($path);
// Scan all PHP files
$pattern = FilePattern::php();
$files = $this->fileScanner->findFiles($filePath, $pattern);
foreach ($files as $file) {
$this->scanFile($file->getPath()->toString(), $registry);
}
}
/**
* Scan single PHP file for attributes
*/
private function scanFile(string $filePath, AttributeRegistry $registry): void
{
// Extract class names from file
$classNames = $this->extractClassNames($filePath);
foreach ($classNames as $className) {
$this->scanClass($className, $filePath, $registry);
}
}
/**
* Scan single class for attributes
*/
private function scanClass(string $className, string $filePath, AttributeRegistry $registry): void
{
try {
$reflection = $this->reflectionProvider->getClass(ClassName::create($className));
// Get all attributes from class
$attributes = $reflection->getAttributes();
foreach ($attributes->toArray() as $attribute) {
$discovered = $this->createDiscoveredAttribute(
$attribute,
$className,
$filePath,
AttributeTarget::TARGET_CLASS
);
$registry->add($attribute->getName(), $discovered);
}
// Also scan methods for attributes (like #[Route] on methods)
foreach ($reflection->getMethods()->toArray() as $method) {
$methodAttributes = $method->getAttributes();
foreach ($methodAttributes->toArray() as $attribute) {
$discovered = $this->createDiscoveredAttribute(
$attribute,
$className,
$filePath,
AttributeTarget::METHOD,
$method->getName()
);
$registry->add($attribute->getName(), $discovered);
}
}
} catch (\Throwable $e) {
// Skip classes that can't be reflected (e.g., missing dependencies)
return;
}
}
/**
* Create DiscoveredAttribute from WrappedReflectionAttribute
*/
private function createDiscoveredAttribute(
WrappedReflectionAttribute $attribute,
string $className,
string $filePath,
AttributeTarget $target,
?string $methodName = null
): DiscoveredAttribute {
// Get the actual attribute instance to extract arguments
$instance = $attribute->newInstance();
// Extract arguments from the instance if available
$arguments = [];
if ($instance) {
// Get reflection of the attribute instance to extract constructor arguments
$reflection = new \ReflectionObject($instance);
$constructor = $reflection->getConstructor();
if ($constructor) {
foreach ($constructor->getParameters() as $param) {
$propName = $param->getName();
if ($reflection->hasProperty($propName)) {
$property = $reflection->getProperty($propName);
// PHP 8.4+ properties are always accessible, no need for setAccessible()
$arguments[$propName] = $property->getValue($instance);
}
}
}
}
return new DiscoveredAttribute(
className: ClassName::create($className),
attributeClass: $attribute->getName(),
target: $target,
methodName: $methodName ? MethodName::create($methodName) : null,
propertyName: null,
arguments: $arguments,
filePath: FilePath::create($filePath),
additionalData: []
);
}
/**
* Extract class names from PHP file
*
* Simple regex-based extraction - faster than full parsing
*
* @return array<string>
*/
private function extractClassNames(string $filePath): array
{
$content = file_get_contents($filePath);
if ($content === false) {
return [];
}
$classNames = [];
// Extract namespace
$namespace = '';
if (preg_match('/^namespace\s+([a-zA-Z0-9\\\\]+);/m', $content, $matches)) {
$namespace = $matches[1];
}
// Extract class/interface/trait/enum names
$pattern = '/^(?:final\s+)?(?:readonly\s+)?(?:abstract\s+)?(class|interface|trait|enum)\s+([a-zA-Z0-9_]+)/m';
if (preg_match_all($pattern, $content, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$className = $match[2];
$classNames[] = $namespace ? $namespace . '\\' . $className : $className;
}
}
return $classNames;
}
}

View File

@@ -1,141 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Framework\BuildTime\Discovery\Scanners;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Discovery\Results\InterfaceRegistry;
use App\Framework\Discovery\ValueObjects\InterfaceMapping;
use App\Framework\Filesystem\FilePath;
use App\Framework\Filesystem\FileScanner;
use App\Framework\Filesystem\ValueObjects\FilePattern;
use App\Framework\Reflection\ReflectionProvider;
/**
* Specialized scanner for interface implementations
*
* Finds all classes that implement specific interfaces.
* Useful for DI container auto-wiring and service discovery.
*/
final readonly class InterfaceScanner
{
public function __construct(
private FileScanner $fileScanner,
private ReflectionProvider $reflectionProvider,
/** @var array<int, class-string> */
private array $targetInterfaces = []
) {
}
/**
* Scan directories for interface implementations
*
* @param array<string> $paths
*/
public function scan(array $paths): InterfaceRegistry
{
$registry = new InterfaceRegistry();
foreach ($paths as $path) {
$this->scanDirectory($path, $registry);
}
return $registry;
}
/**
* Scan single directory for interface implementations
*/
private function scanDirectory(string $path, InterfaceRegistry $registry): void
{
$filePath = FilePath::create($path);
// Scan all PHP files
$pattern = FilePattern::php();
$files = $this->fileScanner->findFiles($filePath, $pattern);
foreach ($files as $file) {
$this->scanFile($file->getPath()->toString(), $registry);
}
}
/**
* Scan single PHP file for interface implementations
*/
private function scanFile(string $filePath, InterfaceRegistry $registry): void
{
// Extract class names from file
$classNames = $this->extractClassNames($filePath);
foreach ($classNames as $className) {
$this->scanClass($className, $filePath, $registry);
}
}
/**
* Scan single class for interface implementations
*/
private function scanClass(string $className, string $filePath, InterfaceRegistry $registry): void
{
try {
$reflection = $this->reflectionProvider->getClass(ClassName::create($className));
// Skip interfaces and traits
if ($reflection->isInterface() || $reflection->isTrait()) {
return;
}
// Get all interfaces this class implements
$interfaces = $reflection->getInterfaceNames();
foreach ($interfaces as $interfaceName) {
// If we have target interfaces, only add those
if (!empty($this->targetInterfaces) && !in_array($interfaceName, $this->targetInterfaces, true)) {
continue;
}
// Create mapping with file path
$mapping = InterfaceMapping::create($interfaceName, $className, $filePath);
$registry->add($mapping);
}
} catch (\Throwable $e) {
// Skip classes that can't be reflected
return;
}
}
/**
* Extract class names from PHP file
*
* @return array<string>
*/
private function extractClassNames(string $filePath): array
{
$content = file_get_contents($filePath);
if ($content === false) {
return [];
}
$classNames = [];
// Extract namespace
$namespace = '';
if (preg_match('/^namespace\s+([a-zA-Z0-9\\\\]+);/m', $content, $matches)) {
$namespace = $matches[1];
}
// Extract class/enum names (not interfaces or traits)
$pattern = '/^(?:final\s+)?(?:readonly\s+)?(?:abstract\s+)?(class|enum)\s+([a-zA-Z0-9_]+)/m';
if (preg_match_all($pattern, $content, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$className = $match[2];
$classNames[] = $namespace ? $namespace . '\\' . $className : $className;
}
}
return $classNames;
}
}

View File

@@ -1,95 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Framework\BuildTime\Discovery\Scanners;
use App\Framework\Discovery\Results\TemplateRegistry;
use App\Framework\Discovery\ValueObjects\TemplateMapping;
use App\Framework\Filesystem\FilePath;
use App\Framework\Filesystem\FileScanner;
use App\Framework\Filesystem\ValueObjects\FilePattern;
/**
* Specialized scanner for template files (.view.php)
*
* Much faster than UnifiedDiscoveryService because it:
* - Only looks for .view.php files
* - Doesn't parse PHP code or extract classes
* - Simple file-to-template mapping
*/
final readonly class TemplateScanner
{
public function __construct(
private FileScanner $fileScanner
) {
}
/**
* Scan directories for .view.php templates
*
* @param array<string> $paths
*/
public function scan(array $paths): TemplateRegistry
{
$registry = new TemplateRegistry();
foreach ($paths as $path) {
$this->scanDirectory($path, $registry);
}
return $registry;
}
/**
* Scan single directory for templates
*/
private function scanDirectory(string $path, TemplateRegistry $registry): void
{
$filePath = FilePath::create($path);
// Only scan for .view.php files
$pattern = FilePattern::create('*.view.php');
$files = $this->fileScanner->findFiles($filePath, $pattern);
foreach ($files as $file) {
$fullPath = $file->getPath()->toString();
// Extract template name from filename
$templateName = $this->extractTemplateName($fullPath);
// Create mapping
$mapping = TemplateMapping::create($templateName, $fullPath);
$registry->add($mapping);
}
}
/**
* Extract template name from file path
*
* Examples:
* - /path/to/dashboard.view.php → dashboard
* - /path/to/admin/users.view.php → admin/users
*/
private function extractTemplateName(string $filePath): string
{
// Remove .view.php extension
$withoutExtension = str_replace('.view.php', '', $filePath);
// Get relative path from src/ or resources/
if (str_contains($withoutExtension, '/src/')) {
$parts = explode('/src/', $withoutExtension);
$relativePath = end($parts);
} elseif (str_contains($withoutExtension, '/resources/')) {
$parts = explode('/resources/', $withoutExtension);
$relativePath = end($parts);
} else {
// Fallback: just use basename
$relativePath = basename($withoutExtension);
}
// Remove leading slashes
return ltrim($relativePath, '/');
}
}

View File

@@ -16,9 +16,9 @@ final readonly class CachePrefix implements CacheIdentifier
private const string PREFIX_MARKER = 'prefix:';
private function __construct(
private string $prefix
private string $value
) {
$this->validate($prefix);
$this->validate($value);
}
/**
@@ -82,7 +82,15 @@ final readonly class CachePrefix implements CacheIdentifier
*/
public function toString(): string
{
return $this->prefix;
return $this->value;
}
/**
* Magic method to allow automatic string casting
*/
public function __toString(): string
{
return $this->value;
}
/**
@@ -98,7 +106,7 @@ final readonly class CachePrefix implements CacheIdentifier
*/
public function equals(CacheIdentifier $other): bool
{
return $other instanceof self && $this->prefix === $other->prefix;
return $other instanceof self && $this->value === $other->value;
}
/**
@@ -106,7 +114,7 @@ final readonly class CachePrefix implements CacheIdentifier
*/
public function matchesKey(CacheKey $key): bool
{
return str_starts_with($key->toString(), $this->prefix);
return str_starts_with($key->toString(), $this->value);
}
/**
@@ -114,7 +122,7 @@ final readonly class CachePrefix implements CacheIdentifier
*/
public function getNormalizedString(): string
{
return self::PREFIX_MARKER . $this->prefix;
return self::PREFIX_MARKER . $this->value;
}
/**
@@ -122,7 +130,7 @@ final readonly class CachePrefix implements CacheIdentifier
*/
public function createKey(string $suffix): CacheKey
{
return CacheKey::fromString($this->prefix . $suffix);
return CacheKey::fromString($this->value . $suffix);
}
/**
@@ -130,8 +138,8 @@ final readonly class CachePrefix implements CacheIdentifier
*/
public function removeFromKey(string $key): string
{
if (str_starts_with($key, $this->prefix)) {
return substr($key, strlen($this->prefix));
if (str_starts_with($key, $this->value)) {
return substr($key, strlen($this->value));
}
return $key;
@@ -142,7 +150,7 @@ final readonly class CachePrefix implements CacheIdentifier
*/
public function hasTrailingSeparator(): bool
{
return str_ends_with($this->prefix, ':');
return str_ends_with($this->value, ':');
}
/**
@@ -154,7 +162,7 @@ final readonly class CachePrefix implements CacheIdentifier
return $this;
}
return new self($this->prefix . ':');
return new self($this->value . ':');
}
/**

View File

@@ -36,7 +36,9 @@ final readonly class FileCache implements CacheDriver, Scannable
private function getFilesForKey(CacheKey $key): array
{
$keyString = (string)$key;
$hash = md5($keyString);
// FIXED: Use same sanitization as getFileName() for consistent hashing
$safeKey = preg_replace('/[^a-zA-Z0-9_\-]/', '_', $keyString);
$hash = md5($safeKey);
$pattern = self::CACHE_PATH . DIRECTORY_SEPARATOR . $hash . '*.cache.php';
@@ -46,7 +48,9 @@ final readonly class FileCache implements CacheDriver, Scannable
private function getLockFileName(CacheKey $key): string
{
$keyString = (string)$key;
$hash = md5($keyString);
// FIXED: Use same sanitization as getFileName() for consistent hashing
$safeKey = preg_replace('/[^a-zA-Z0-9_\-]/', '_', $keyString);
$hash = md5($safeKey);
return self::CACHE_PATH . DIRECTORY_SEPARATOR . $hash . '.lock';
}
@@ -98,7 +102,15 @@ final readonly class FileCache implements CacheDriver, Scannable
return CacheItem::miss($key);
}
$content = $this->fileSystem->get($bestFile);
try {
$content = $this->fileSystem->get($bestFile);
} catch (\App\Framework\Filesystem\Exceptions\FileNotFoundException $e) {
// File was deleted between finding and reading (race condition)
return CacheItem::miss($key);
} catch (\App\Framework\Filesystem\Exceptions\FilePermissionException $e) {
// File permissions changed or file being written (race condition)
return CacheItem::miss($key);
}
if ($content === null || $content === '') {
$this->fileSystem->delete($bestFile);
@@ -193,7 +205,7 @@ final readonly class FileCache implements CacheDriver, Scannable
public function clear(): bool
{
$files = glob(self::CACHE_PATH . '*.cache.php') ?: [];
$files = glob(self::CACHE_PATH . '/*.cache.php') ?: [];
foreach ($files as $file) {
$this->fileSystem->delete($file);
}
@@ -209,7 +221,7 @@ final readonly class FileCache implements CacheDriver, Scannable
$matches = [];
$count = 0;
$files = glob(self::CACHE_PATH . '*.cache.php') ?: [];
$files = glob(self::CACHE_PATH . '/*.cache.php') ?: [];
foreach ($files as $file) {
if ($limit > 0 && $count >= $limit) {
@@ -231,7 +243,7 @@ final readonly class FileCache implements CacheDriver, Scannable
$matches = [];
$count = 0;
$files = glob(self::CACHE_PATH . '*.cache.php') ?: [];
$files = glob(self::CACHE_PATH . '/*.cache.php') ?: [];
foreach ($files as $file) {
if ($limit > 0 && $count >= $limit) {
@@ -253,7 +265,7 @@ final readonly class FileCache implements CacheDriver, Scannable
$keys = [];
$count = 0;
$files = glob(self::CACHE_PATH . '*.cache.php') ?: [];
$files = glob(self::CACHE_PATH . '/*.cache.php') ?: [];
foreach ($files as $file) {
if ($limit > 0 && $count >= $limit) {

View File

@@ -9,7 +9,7 @@ use App\Framework\Cache\Events\CacheDelete;
use App\Framework\Cache\Events\CacheHit;
use App\Framework\Cache\Events\CacheMiss;
use App\Framework\Cache\Events\CacheSet;
use App\Framework\Core\Events\EventDispatcher;
use App\Framework\EventBus\EventBus;
use App\Framework\Core\ValueObjects\Duration;
/**
@@ -22,7 +22,7 @@ final readonly class EventCacheDecorator implements Cache
{
public function __construct(
private Cache $innerCache,
private EventDispatcher $eventDispatcher
private EventBus $eventBus
) {
}
@@ -33,9 +33,9 @@ final readonly class EventCacheDecorator implements Cache
foreach ($result->getItems() as $item) {
if ($item->isHit) {
$valueSize = $this->calculateValueSize($item->value);
$this->eventDispatcher->dispatch(CacheHit::create($item->key, $item->value, $valueSize));
$this->eventBus->dispatch(CacheHit::create($item->key, $item->value, $valueSize));
} else {
$this->eventDispatcher->dispatch(CacheMiss::create($item->key));
$this->eventBus->dispatch(CacheMiss::create($item->key));
}
}
@@ -48,7 +48,7 @@ final readonly class EventCacheDecorator implements Cache
foreach ($items as $item) {
$valueSize = $this->calculateValueSize($item->value);
$this->eventDispatcher->dispatch(CacheSet::create($item->key, $item->value, $item->ttl, $result, $valueSize));
$this->eventBus->dispatch(CacheSet::create($item->key, $item->value, $item->ttl, $result, $valueSize));
}
return $result;
@@ -65,7 +65,7 @@ final readonly class EventCacheDecorator implements Cache
foreach ($identifiers as $identifier) {
if ($identifier instanceof CacheKey) {
$this->eventDispatcher->dispatch(CacheDelete::create($identifier, $result));
$this->eventBus->dispatch(CacheDelete::create($identifier, $result));
}
}
@@ -76,7 +76,7 @@ final readonly class EventCacheDecorator implements Cache
{
$result = $this->innerCache->clear();
$this->eventDispatcher->dispatch(CacheClear::create($result));
$this->eventBus->dispatch(CacheClear::create($result));
return $result;
}
@@ -89,19 +89,19 @@ final readonly class EventCacheDecorator implements Cache
if ($existing->isHit) {
$valueSize = $this->calculateValueSize($existing->value);
$this->eventDispatcher->dispatch(CacheHit::create($key, $existing->value, $valueSize));
$this->eventBus->dispatch(CacheHit::create($key, $existing->value, $valueSize));
return $existing;
}
// Cache miss - execute callback
$this->eventDispatcher->dispatch(CacheMiss::create($key));
$this->eventBus->dispatch(CacheMiss::create($key));
$result = $this->innerCache->remember($key, $callback, $ttl);
if (! $result->isHit) {
$valueSize = $this->calculateValueSize($result->value);
$this->eventDispatcher->dispatch(CacheSet::create($key, $result->value, $ttl, true, $valueSize));
$this->eventBus->dispatch(CacheSet::create($key, $result->value, $ttl, true, $valueSize));
}
return $result;

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Framework\Cache\Events;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Event fired when the entire cache is cleared
*/
@@ -11,7 +13,7 @@ final readonly class CacheClear
{
public function __construct(
public bool $success,
public float $timestamp = 0.0
public Timestamp $timestamp
) {
}
@@ -20,6 +22,6 @@ final readonly class CacheClear
*/
public static function create(bool $success): self
{
return new self($success, microtime(true));
return new self($success, Timestamp::now());
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Framework\Cache\Events;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Event fired when a key is deleted from cache
@@ -14,7 +15,7 @@ final readonly class CacheDelete
public function __construct(
public CacheKey $key,
public bool $success,
public float $timestamp = 0.0
public Timestamp $timestamp
) {
}
@@ -23,6 +24,6 @@ final readonly class CacheDelete
*/
public static function create(CacheKey $key, bool $success): self
{
return new self($key, $success, microtime(true));
return new self($key, $success, Timestamp::now());
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Framework\Cache\Events;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Event fired when a cache hit occurs
@@ -14,8 +15,8 @@ final readonly class CacheHit
public function __construct(
public CacheKey $key,
public mixed $value,
public int $valueSize = 0,
public float $timestamp = 0.0
public int $valueSize,
public Timestamp $timestamp
) {
}
@@ -24,6 +25,6 @@ final readonly class CacheHit
*/
public static function create(CacheKey $key, mixed $value, int $valueSize = 0): self
{
return new self($key, $value, $valueSize, microtime(true));
return new self($key, $value, $valueSize, Timestamp::now());
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Framework\Cache\Events;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Event fired when a cache miss occurs
@@ -13,7 +14,7 @@ final readonly class CacheMiss
{
public function __construct(
public CacheKey $key,
public float $timestamp = 0.0
public Timestamp $timestamp
) {
}
@@ -22,6 +23,6 @@ final readonly class CacheMiss
*/
public static function create(CacheKey $key): self
{
return new self($key, microtime(true));
return new self($key, Timestamp::now());
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Framework\Cache\Events;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Event fired when a value is set in cache
@@ -17,8 +18,8 @@ final readonly class CacheSet
public mixed $value,
public ?Duration $ttl,
public bool $success,
public int $valueSize = 0,
public float $timestamp = 0.0
public int $valueSize,
public Timestamp $timestamp
) {
}
@@ -32,6 +33,6 @@ final readonly class CacheSet
bool $success,
int $valueSize = 0
): self {
return new self($key, $value, $ttl, $success, $valueSize, microtime(true));
return new self($key, $value, $ttl, $success, $valueSize, Timestamp::now());
}
}

View File

@@ -157,14 +157,15 @@ final readonly class MultiLevelCache implements Cache, DriverAccessible
public function remember(CacheKey $key, callable $callback, ?Duration $ttl = null): CacheItem
{
$item = $this->get($key);
$result = $this->get($key);
$item = $result->getItem($key);
if ($item->isHit) {
return $item;
}
// Wert generieren, speichern und zurückgeben
$value = $callback();
$this->set($key, $value, $ttl);
$this->set(CacheItem::forSet($key, $value, $ttl));
// Erstelle neuen CacheItem als Treffer
return CacheItem::hit($key, $value);

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Framework\Cache;
use App\Framework\Cache\Contracts\DriverAccessible;
use App\Framework\Core\ValueObjects\Duration;
use ReflectionException;
use ReflectionMethod;
@@ -26,15 +27,45 @@ final readonly class ServiceCacheDecorator implements DriverAccessible
if ($attrs) {
$attr = $attrs[0]->newInstance();
$key = $attr->key ?? $method->getName() . ':' . md5(serialize($args));
$ttl = $attr->ttl ?? 3600;
$keyString = $attr->key ?? $method->getName() . ':' . md5(serialize($args));
return $this->cache->remember($key, fn () => $method->invokeArgs($this->service, $args), $ttl);
// Replace placeholders in custom key templates
if ($attr->key !== null) {
$keyString = $this->replacePlaceholders($keyString, $method, $args);
}
$cacheKey = CacheKey::fromString($keyString);
$ttl = Duration::fromSeconds($attr->ttl ?? 3600);
$cacheItem = $this->cache->remember($cacheKey, fn () => $method->invokeArgs($this->service, $args), $ttl);
return $cacheItem->value;
}
return $method->invokeArgs($this->service, $args);
}
/**
* Replace placeholders in cache key template with actual argument values
*/
private function replacePlaceholders(string $template, ReflectionMethod $method, array $args): string
{
$parameters = $method->getParameters();
$result = $template;
foreach ($parameters as $index => $parameter) {
$paramName = $parameter->getName();
$paramValue = $args[$index] ?? null;
if ($paramValue !== null) {
// Convert value to string for cache key
$stringValue = is_scalar($paramValue) ? (string) $paramValue : md5(serialize($paramValue));
$result = str_replace('{' . $paramName . '}', $stringValue, $result);
}
}
return $result;
}
/**
* Get the underlying cache driver
*/

View File

@@ -28,6 +28,7 @@ final class SmartCache implements Cache, DriverAccessible
private const int LARGE_BATCH_THRESHOLD = 20; // Extra optimization for large batches
private readonly ?TagIndex $tagIndex;
private readonly ?CacheStrategyManager $strategyManager;
public function __construct(
@@ -475,8 +476,8 @@ final class SmartCache implements Cache, DriverAccessible
// Convert string keys back to CacheKey objects
$cacheKeys = array_map(fn (string $key) => CacheKey::fromString($key), $matchingKeys);
// Batch load all matching keys
return $cacheDriver->get(...$cacheKeys);
// Batch load all matching keys through innerCache (for proper deserialization)
return $this->innerCache->get(...$cacheKeys);
} catch (\Throwable $e) {
// On any error, return empty result
@@ -499,7 +500,7 @@ final class SmartCache implements Cache, DriverAccessible
try {
// Get matching keys by prefix (more efficient than pattern matching)
$matchingKeys = $cacheDriver->scanPrefix($prefix->value, 1000);
$matchingKeys = $cacheDriver->scanPrefix($prefix->toString(), 1000);
if (empty($matchingKeys)) {
return CacheResult::empty();
@@ -508,8 +509,8 @@ final class SmartCache implements Cache, DriverAccessible
// Convert string keys back to CacheKey objects
$cacheKeys = array_map(fn (string $key) => CacheKey::fromString($key), $matchingKeys);
// Batch load all matching keys
return $cacheDriver->get(...$cacheKeys);
// Batch load all matching keys through innerCache (for proper deserialization)
return $this->innerCache->get(...$cacheKeys);
} catch (\Throwable $e) {
// On any error, return empty result
@@ -586,7 +587,7 @@ final class SmartCache implements Cache, DriverAccessible
try {
// Get matching keys by prefix
$matchingKeys = $cacheDriver->scanPrefix($prefix->value, 1000);
$matchingKeys = $cacheDriver->scanPrefix($prefix->toString(), 1000);
$results = [];
foreach ($matchingKeys as $key) {
@@ -651,8 +652,8 @@ final class SmartCache implements Cache, DriverAccessible
// Convert string keys back to CacheKey objects
$cacheKeys = array_map(fn (string $key) => CacheKey::fromString($key), $matchingKeys);
// Batch delete all matching keys
$cacheDriver->forget(...$cacheKeys);
// Batch delete all matching keys through innerCache
$this->innerCache->forget(...$cacheKeys);
return count($matchingKeys);
@@ -676,7 +677,7 @@ final class SmartCache implements Cache, DriverAccessible
try {
// Get matching keys by prefix
$matchingKeys = $cacheDriver->scanPrefix($prefix->value, 1000);
$matchingKeys = $cacheDriver->scanPrefix($prefix->toString(), 1000);
if (empty($matchingKeys)) {
return 0; // Nothing to delete
@@ -685,8 +686,8 @@ final class SmartCache implements Cache, DriverAccessible
// Convert string keys back to CacheKey objects
$cacheKeys = array_map(fn (string $key) => CacheKey::fromString($key), $matchingKeys);
// Batch delete all matching keys
$cacheDriver->forget(...$cacheKeys);
// Batch delete all matching keys through innerCache
$this->innerCache->forget(...$cacheKeys);
return count($matchingKeys);
@@ -1056,11 +1057,12 @@ final class SmartCache implements Cache, DriverAccessible
*/
public function getStrategyStats(string $strategyName): ?array
{
if (!$this->strategyManager) {
if (! $this->strategyManager) {
return null;
}
$strategy = $this->strategyManager->getStrategy($strategyName);
return $strategy?->getStats();
}

View File

@@ -29,6 +29,7 @@ final class AdaptiveTtlCacheStrategy implements CacheStrategy
private array $itemStats = [];
private readonly Duration $minTtl;
private readonly Duration $maxTtl;
public function __construct(
@@ -45,20 +46,20 @@ final class AdaptiveTtlCacheStrategy implements CacheStrategy
public function onCacheAccess(CacheKey $key, bool $isHit, ?Duration $retrievalTime = null): void
{
if (!$this->enabled) {
if (! $this->enabled) {
return;
}
$keyString = $key->toString();
// Record access pattern
if (!isset($this->accessPatterns[$keyString])) {
if (! isset($this->accessPatterns[$keyString])) {
$this->accessPatterns[$keyString] = new AccessPattern($this->learningWindow);
}
$this->accessPatterns[$keyString]->recordAccess();
// Record hit/miss statistics
if (!isset($this->itemStats[$keyString])) {
if (! isset($this->itemStats[$keyString])) {
$this->itemStats[$keyString] = new AdaptiveTtlStats();
}
$this->itemStats[$keyString]->recordHitMiss($isHit);
@@ -66,7 +67,7 @@ final class AdaptiveTtlCacheStrategy implements CacheStrategy
public function onCacheSet(CacheKey $key, mixed $value, ?Duration $originalTtl): Duration
{
if (!$this->enabled) {
if (! $this->enabled) {
return $originalTtl ?? Duration::fromHours(1);
}
@@ -75,7 +76,7 @@ final class AdaptiveTtlCacheStrategy implements CacheStrategy
public function onCacheForget(CacheKey $key): void
{
if (!$this->enabled) {
if (! $this->enabled) {
return;
}
@@ -99,7 +100,7 @@ final class AdaptiveTtlCacheStrategy implements CacheStrategy
'extension_factor' => $this->extensionFactor,
'reduction_factor' => $this->reductionFactor,
],
'key_patterns' => []
'key_patterns' => [],
];
// Include top accessed keys and their patterns
@@ -154,7 +155,7 @@ final class AdaptiveTtlCacheStrategy implements CacheStrategy
$baseTtl = $originalTtl ?? Duration::fromHours(1);
$baseSeconds = $baseTtl->toSeconds();
if (!$pattern) {
if (! $pattern) {
// No access pattern yet, use original TTL
return $this->enforceTtlBounds($baseTtl);
}
@@ -213,7 +214,8 @@ final class AccessPattern
public function __construct(
private readonly int $windowSize
) {}
) {
}
public function recordAccess(): void
{
@@ -253,6 +255,7 @@ final class AccessPattern
}
$hours = $timeSpan->toSeconds() / 3600.0;
return count($this->accessTimes) / $hours;
}
}
@@ -263,6 +266,7 @@ final class AccessPattern
final class AdaptiveTtlStats
{
private int $hits = 0;
private int $misses = 0;
public function recordHitMiss(bool $isHit): void
@@ -277,6 +281,7 @@ final class AdaptiveTtlStats
public function getHitRate(): float
{
$total = $this->hits + $this->misses;
return $total > 0 ? ($this->hits / $total) : 0.0;
}
@@ -294,4 +299,4 @@ final class AdaptiveTtlStats
{
return $this->misses;
}
}
}

View File

@@ -66,4 +66,4 @@ interface CacheStrategy
* @return bool Whether strategy is active
*/
public function isEnabled(): bool;
}
}

View File

@@ -23,7 +23,8 @@ final class CacheStrategyManager
public function __construct(
private readonly bool $enabled = true
) {}
) {
}
/**
* Register a cache strategy
@@ -48,7 +49,7 @@ final class CacheStrategyManager
unset($this->strategies[$name]);
$this->enabledStrategies = array_filter(
$this->enabledStrategies,
fn($strategyName) => $strategyName !== $name
fn ($strategyName) => $strategyName !== $name
);
return $this;
@@ -59,7 +60,7 @@ final class CacheStrategyManager
*/
public function enableStrategy(string $name): self
{
if (isset($this->strategies[$name]) && !in_array($name, $this->enabledStrategies)) {
if (isset($this->strategies[$name]) && ! in_array($name, $this->enabledStrategies)) {
$this->enabledStrategies[] = $name;
}
@@ -73,7 +74,7 @@ final class CacheStrategyManager
{
$this->enabledStrategies = array_filter(
$this->enabledStrategies,
fn($strategyName) => $strategyName !== $name
fn ($strategyName) => $strategyName !== $name
);
return $this;
@@ -84,7 +85,7 @@ final class CacheStrategyManager
*/
public function notifyAccess(CacheKey $key, bool $isHit, ?Duration $retrievalTime = null): void
{
if (!$this->enabled) {
if (! $this->enabled) {
return;
}
@@ -103,7 +104,7 @@ final class CacheStrategyManager
*/
public function notifySet(CacheKey $key, mixed $value, ?Duration $originalTtl): Duration
{
if (!$this->enabled) {
if (! $this->enabled) {
return $originalTtl ?? Duration::fromHours(1);
}
@@ -126,7 +127,7 @@ final class CacheStrategyManager
*/
public function notifyForget(CacheKey $key): void
{
if (!$this->enabled) {
if (! $this->enabled) {
return;
}
@@ -153,7 +154,7 @@ final class CacheStrategyManager
'strategy_names' => array_keys($this->strategies),
'enabled_strategy_names' => $this->enabledStrategies,
],
'strategies' => []
'strategies' => [],
];
foreach ($this->strategies as $name => $strategy) {
@@ -161,7 +162,7 @@ final class CacheStrategyManager
$stats['strategies'][$name] = $strategy->getStats();
} catch (\Throwable $e) {
$stats['strategies'][$name] = [
'error' => 'Failed to get stats: ' . $e->getMessage()
'error' => 'Failed to get stats: ' . $e->getMessage(),
];
}
}
@@ -311,4 +312,4 @@ final class CacheStrategyManager
return $manager;
}
}
}

View File

@@ -33,22 +33,23 @@ final class HeatMapCacheStrategy implements CacheStrategy
private readonly int $hotThreshold = self::DEFAULT_HOT_THRESHOLD,
private readonly int $coldThreshold = self::DEFAULT_COLD_THRESHOLD,
private readonly int $analysisWindowHours = self::DEFAULT_ANALYSIS_WINDOW_HOURS
) {}
) {
}
public function onCacheAccess(CacheKey $key, bool $isHit, ?Duration $retrievalTime = null): void
{
if (!$this->enabled) {
if (! $this->enabled) {
return;
}
$keyString = $key->toString();
// Prevent memory overflow by limiting tracked keys
if (!isset($this->heatMap[$keyString]) && count($this->heatMap) >= $this->maxTrackedKeys) {
if (! isset($this->heatMap[$keyString]) && count($this->heatMap) >= $this->maxTrackedKeys) {
$this->evictOldestEntry();
}
if (!isset($this->heatMap[$keyString])) {
if (! isset($this->heatMap[$keyString])) {
$this->heatMap[$keyString] = new HeatMapEntry($key);
}
@@ -57,7 +58,7 @@ final class HeatMapCacheStrategy implements CacheStrategy
public function onCacheSet(CacheKey $key, mixed $value, ?Duration $originalTtl): Duration
{
if (!$this->enabled) {
if (! $this->enabled) {
return $originalTtl ?? Duration::fromHours(1);
}
@@ -77,7 +78,7 @@ final class HeatMapCacheStrategy implements CacheStrategy
public function onCacheForget(CacheKey $key): void
{
if (!$this->enabled) {
if (! $this->enabled) {
return;
}
@@ -175,8 +176,8 @@ final class HeatMapCacheStrategy implements CacheStrategy
}
// Sort by priority
usort($hotKeys, fn($a, $b) => $b['accesses_per_hour'] <=> $a['accesses_per_hour']);
usort($coldKeys, fn($a, $b) => $a['accesses_per_hour'] <=> $b['accesses_per_hour']);
usort($hotKeys, fn ($a, $b) => $b['accesses_per_hour'] <=> $a['accesses_per_hour']);
usort($coldKeys, fn ($a, $b) => $a['accesses_per_hour'] <=> $b['accesses_per_hour']);
return [
'total_tracked_keys' => count($this->heatMap),
@@ -231,7 +232,7 @@ final class HeatMapCacheStrategy implements CacheStrategy
}
// Sort by impact score (highest first)
usort($bottlenecks, fn($a, $b) => $b['impact_score'] <=> $a['impact_score']);
usort($bottlenecks, fn ($a, $b) => $b['impact_score'] <=> $a['impact_score']);
return array_slice($bottlenecks, 0, 20); // Top 20 bottlenecks
}
@@ -254,6 +255,7 @@ final class HeatMapCacheStrategy implements CacheStrategy
}
arsort($hotKeys);
return array_slice($hotKeys, 0, $limit, true);
}
@@ -295,6 +297,7 @@ final class HeatMapCacheStrategy implements CacheStrategy
}
$hours = $this->analysisWindowHours;
return count($accesses) / $hours;
}
@@ -342,13 +345,17 @@ final class HeatMapEntry
private array $accesses = [];
private int $totalHits = 0;
private int $totalMisses = 0;
private float $totalRetrievalTime = 0.0;
private int $retrievalTimeCount = 0;
public function __construct(
private readonly CacheKey $key
) {}
) {
}
public function recordAccess(bool $isHit, ?Duration $retrievalTime = null): void
{
@@ -373,7 +380,7 @@ final class HeatMapEntry
$cutoff = Timestamp::now()->subtract(Duration::fromHours(48)); // Keep 48 hours
$this->accesses = array_filter(
$this->accesses,
fn($access) => $access['timestamp']->isAfter($cutoff)
fn ($access) => $access['timestamp']->isAfter($cutoff)
);
}
@@ -381,13 +388,14 @@ final class HeatMapEntry
{
return array_filter(
$this->accesses,
fn($access) => $access['timestamp']->isAfter($since)
fn ($access) => $access['timestamp']->isAfter($since)
);
}
public function getHitRate(): float
{
$total = $this->totalHits + $this->totalMisses;
return $total > 0 ? ($this->totalHits / $total) : 0.0;
}
@@ -426,5 +434,6 @@ final readonly class WriteOperation
public int $valueSize,
public Duration $writeTime,
public Timestamp $timestamp
) {}
}
) {
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Framework\Cache\Strategies;
use App\Framework\Cache\CacheKey;
use App\Framework\Cache\CacheItem;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Timestamp;
@@ -39,11 +38,12 @@ final class PredictiveCacheStrategy implements CacheStrategy
private readonly int $predictionWindowHours = self::DEFAULT_PREDICTION_WINDOW_HOURS,
private readonly float $confidenceThreshold = self::DEFAULT_CONFIDENCE_THRESHOLD,
private readonly int $maxConcurrentWarming = self::MAX_CONCURRENT_WARMING
) {}
) {
}
public function onCacheAccess(CacheKey $key, bool $isHit, ?Duration $retrievalTime = null): void
{
if (!$this->enabled) {
if (! $this->enabled) {
return;
}
@@ -63,7 +63,7 @@ final class PredictiveCacheStrategy implements CacheStrategy
public function onCacheForget(CacheKey $key): void
{
if (!$this->enabled) {
if (! $this->enabled) {
return;
}
@@ -130,14 +130,14 @@ final class PredictiveCacheStrategy implements CacheStrategy
*/
public function recordAccess(CacheKey $key, array $context = []): void
{
if (!$this->enabled) {
if (! $this->enabled) {
return;
}
$keyString = $key->toString();
$timestamp = Timestamp::now();
if (!isset($this->patterns[$keyString])) {
if (! isset($this->patterns[$keyString])) {
$this->patterns[$keyString] = new PredictionPattern($key);
}
@@ -149,13 +149,13 @@ final class PredictiveCacheStrategy implements CacheStrategy
*/
public function recordDependency(CacheKey $primaryKey, CacheKey $dependentKey): void
{
if (!$this->enabled) {
if (! $this->enabled) {
return;
}
$primaryString = $primaryKey->toString();
if (!isset($this->patterns[$primaryString])) {
if (! isset($this->patterns[$primaryString])) {
$this->patterns[$primaryString] = new PredictionPattern($primaryKey);
}
@@ -167,7 +167,7 @@ final class PredictiveCacheStrategy implements CacheStrategy
*/
public function generatePredictions(): array
{
if (!$this->enabled) {
if (! $this->enabled) {
return [];
}
@@ -180,7 +180,7 @@ final class PredictiveCacheStrategy implements CacheStrategy
}
// Sort by confidence score
usort($predictions, fn($a, $b) => $b['confidence'] <=> $a['confidence']);
usort($predictions, fn ($a, $b) => $b['confidence'] <=> $a['confidence']);
return $predictions;
}
@@ -190,7 +190,7 @@ final class PredictiveCacheStrategy implements CacheStrategy
*/
public function performPredictiveWarming(): array
{
if (!$this->enabled) {
if (! $this->enabled) {
return [];
}
@@ -221,14 +221,14 @@ final class PredictiveCacheStrategy implements CacheStrategy
*/
public function registerWarmingCallback(CacheKey $key, callable $callback): void
{
if (!$this->enabled) {
if (! $this->enabled) {
return;
}
$keyString = $key->toString();
$this->warmingCallbacks[$keyString] = $callback;
if (!isset($this->patterns[$keyString])) {
if (! isset($this->patterns[$keyString])) {
$this->patterns[$keyString] = new PredictionPattern($key);
}
@@ -251,7 +251,7 @@ final class PredictiveCacheStrategy implements CacheStrategy
'confidence' => $timeBasedConfidence,
'reason' => 'time_based_pattern',
'predicted_access_time' => $now->add(Duration::fromMinutes(30)),
'callback' => $pattern->getWarmingCallback()
'callback' => $pattern->getWarmingCallback(),
];
}
@@ -263,7 +263,7 @@ final class PredictiveCacheStrategy implements CacheStrategy
'confidence' => $frequencyConfidence,
'reason' => 'access_frequency_pattern',
'predicted_access_time' => $now->add(Duration::fromMinutes(15)),
'callback' => $pattern->getWarmingCallback()
'callback' => $pattern->getWarmingCallback(),
];
}
@@ -328,7 +328,7 @@ final class PredictiveCacheStrategy implements CacheStrategy
// Calculate average time between accesses
$intervals = [];
for ($i = 1; $i < count($accesses); $i++) {
$interval = $accesses[$i]['timestamp']->diff($accesses[$i-1]['timestamp']);
$interval = $accesses[$i]['timestamp']->diff($accesses[$i - 1]['timestamp']);
$intervals[] = $interval->toSeconds();
}
@@ -372,7 +372,7 @@ final class PredictiveCacheStrategy implements CacheStrategy
'confidence' => $primaryConfidence * 0.8, // Slightly lower confidence for dependencies
'reason' => 'dependency_pattern',
'predicted_access_time' => $now->add(Duration::fromMinutes(5)),
'callback' => $this->warmingCallbacks[$dependentKey->toString()] ?? null
'callback' => $this->warmingCallbacks[$dependentKey->toString()] ?? null,
];
}
}
@@ -394,7 +394,7 @@ final class PredictiveCacheStrategy implements CacheStrategy
'key' => $keyString,
'status' => 'already_warming',
'reason' => $reason,
'timestamp' => $startTime->format('H:i:s')
'timestamp' => $startTime->format('H:i:s'),
];
}
@@ -404,12 +404,12 @@ final class PredictiveCacheStrategy implements CacheStrategy
try {
// If no callback provided, we can't warm the cache
if (!$callback) {
if (! $callback) {
return [
'key' => $keyString,
'status' => 'no_callback',
'reason' => $reason,
'timestamp' => $startTime->format('H:i:s')
'timestamp' => $startTime->format('H:i:s'),
];
}
@@ -428,7 +428,7 @@ final class PredictiveCacheStrategy implements CacheStrategy
'status' => 'warmed',
'reason' => $reason,
'duration_ms' => $duration->toMilliseconds(),
'timestamp' => $startTime->format('H:i:s')
'timestamp' => $startTime->format('H:i:s'),
];
} catch (\Throwable $e) {
@@ -441,7 +441,7 @@ final class PredictiveCacheStrategy implements CacheStrategy
'reason' => $reason,
'error' => $e->getMessage(),
'duration_ms' => $duration->toMilliseconds(),
'timestamp' => $startTime->format('H:i:s')
'timestamp' => $startTime->format('H:i:s'),
];
} finally {
@@ -466,20 +466,21 @@ final class PredictionPattern
public function __construct(
private readonly CacheKey $key
) {}
) {
}
public function recordAccess(Timestamp $timestamp, array $context = []): void
{
$this->accesses[] = [
'timestamp' => $timestamp,
'context' => $context
'context' => $context,
];
// Keep only recent accesses to prevent memory bloat
$cutoff = $timestamp->subtract(Duration::fromHours(168)); // 1 week
$this->accesses = array_filter(
$this->accesses,
fn($access) => $access['timestamp']->isAfter($cutoff)
fn ($access) => $access['timestamp']->isAfter($cutoff)
);
}
@@ -504,7 +505,7 @@ final class PredictionPattern
return array_filter(
$this->accesses,
fn($access) => $access['timestamp']->isAfter($cutoff)
fn ($access) => $access['timestamp']->isAfter($cutoff)
);
}
@@ -529,7 +530,8 @@ final readonly class WarmingJob
public mixed $callback,
public string $reason,
public Timestamp $startTime
) {}
) {
}
}
/**
@@ -542,5 +544,6 @@ final readonly class WarmingResult
public bool $successful,
public Duration $duration,
public string $reason
) {}
}
) {
}
}

View File

@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace App\Framework\Cache\Warming;
use App\Framework\Cache\Warming\ValueObjects\WarmupMetrics;
use App\Framework\Cache\Warming\ValueObjects\WarmupResult;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
/**
* Core service for managing cache warming operations
*
* Coordinates multiple warming strategies and tracks metrics.
*/
final readonly class CacheWarmingService
{
/** @var array<WarmupStrategy> */
private array $strategies;
public function __construct(
private Logger $logger,
array $strategies = []
) {
// Sort strategies by priority (highest first)
$this->strategies = $this->sortStrategiesByPriority($strategies);
}
/**
* Execute all registered warming strategies
*
* @param bool $force Force execution even if shouldRun() returns false
* @return WarmupMetrics Aggregated metrics from all strategies
*/
public function warmAll(bool $force = false): WarmupMetrics
{
$this->logger->info(
'Starting cache warmup',
LogContext::withData([
'strategies_count' => count($this->strategies),
'force' => $force
])
);
$results = [];
foreach ($this->strategies as $strategy) {
if (!$force && !$strategy->shouldRun()) {
$this->logger->debug(
'Skipping strategy (shouldRun returned false)',
LogContext::withData(['strategy' => $strategy->getName()])
);
continue;
}
$result = $this->executeStrategy($strategy);
$results[] = $result;
}
$metrics = WarmupMetrics::fromResults($results);
$this->logger->info(
'Cache warmup completed',
LogContext::withData($metrics->toArray())
);
return $metrics;
}
/**
* Execute a specific warming strategy by name
*
* @param string $strategyName Name of the strategy to execute
* @return WarmupResult Result of the strategy execution
* @throws \InvalidArgumentException If strategy not found
*/
public function warmStrategy(string $strategyName): WarmupResult
{
$strategy = $this->findStrategy($strategyName);
if ($strategy === null) {
throw new \InvalidArgumentException(
"Strategy '{$strategyName}' not found. Available: " .
implode(', ', $this->getStrategyNames())
);
}
return $this->executeStrategy($strategy);
}
/**
* Execute only strategies with a specific minimum priority
*
* @param int $minPriority Minimum priority level
* @return WarmupMetrics Aggregated metrics
*/
public function warmByPriority(int $minPriority): WarmupMetrics
{
$this->logger->info(
'Starting priority-based cache warmup',
LogContext::withData(['min_priority' => $minPriority])
);
$results = [];
foreach ($this->strategies as $strategy) {
if ($strategy->getPriority() >= $minPriority) {
$result = $this->executeStrategy($strategy);
$results[] = $result;
}
}
return WarmupMetrics::fromResults($results);
}
/**
* Get list of all registered strategies
*
* @return array<array{name: string, priority: int, estimated_duration: int}>
*/
public function getStrategies(): array
{
return array_map(function (WarmupStrategy $strategy) {
return [
'name' => $strategy->getName(),
'priority' => $strategy->getPriority(),
'estimated_duration' => $strategy->getEstimatedDuration(),
'should_run' => $strategy->shouldRun()
];
}, $this->strategies);
}
/**
* Get estimated total warmup duration in seconds
*/
public function getEstimatedTotalDuration(): int
{
$total = 0;
foreach ($this->strategies as $strategy) {
if ($strategy->shouldRun()) {
$total += $strategy->getEstimatedDuration();
}
}
return $total;
}
private function executeStrategy(WarmupStrategy $strategy): WarmupResult
{
$this->logger->info(
'Executing warmup strategy',
LogContext::withData([
'strategy' => $strategy->getName(),
'priority' => $strategy->getPriority(),
'estimated_duration' => $strategy->getEstimatedDuration()
])
);
try {
$result = $strategy->warmup();
$this->logger->info(
'Strategy completed',
LogContext::withData(array_merge(
['strategy' => $strategy->getName()],
$result->toArray()
))
);
return $result;
} catch (\Throwable $e) {
$this->logger->error(
'Strategy failed',
LogContext::withData([
'strategy' => $strategy->getName(),
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
])
);
// Return failed result
return new WarmupResult(
strategyName: $strategy->getName(),
itemsWarmed: 0,
itemsFailed: 1,
durationSeconds: 0.0,
memoryUsedBytes: 0,
errors: [['error' => $e->getMessage()]]
);
}
}
private function findStrategy(string $name): ?WarmupStrategy
{
foreach ($this->strategies as $strategy) {
if ($strategy->getName() === $name) {
return $strategy;
}
}
return null;
}
private function getStrategyNames(): array
{
return array_map(fn($s) => $s->getName(), $this->strategies);
}
/**
* @param array<WarmupStrategy> $strategies
* @return array<WarmupStrategy>
*/
private function sortStrategiesByPriority(array $strategies): array
{
usort($strategies, fn($a, $b) => $b->getPriority() <=> $a->getPriority());
return $strategies;
}
}

View File

@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace App\Framework\Cache\Warming\Commands;
use App\Framework\Attributes\ConsoleCommand;
use App\Framework\Cache\Warming\CacheWarmingService;
use App\Framework\Cache\Warming\ValueObjects\WarmupPriority;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Input\ConsoleInput;
/**
* Console command for cache warmup operations
*
* Usage:
* php console.php cache:warmup # Warm all strategies
* php console.php cache:warmup --strategy=critical_path # Warm specific strategy
* php console.php cache:warmup --priority=high # Warm by priority level
* php console.php cache:warmup --list # List available strategies
*/
#[ConsoleCommand(
name: 'cache:warmup',
description: 'Warm up cache using registered warming strategies'
)]
final readonly class CacheWarmupCommand
{
public function __construct(
private CacheWarmingService $warmingService
) {}
public function execute(ConsoleInput $input): int
{
// List strategies
if ($input->getOption('list', false)) {
return $this->listStrategies();
}
// Specific strategy
if ($strategyName = $input->getOption('strategy')) {
return $this->warmStrategy($strategyName);
}
// Priority-based warming
if ($priorityLevel = $input->getOption('priority')) {
return $this->warmByPriority($priorityLevel);
}
// Warm all
return $this->warmAll($input->getOption('force', false));
}
private function warmAll(bool $force): int
{
echo "🔥 Warming all cache strategies...\n\n";
if ($force) {
echo "⚠️ Force mode: Ignoring shouldRun() checks\n\n";
}
$estimatedDuration = $this->warmingService->getEstimatedTotalDuration();
echo "⏱️ Estimated duration: {$estimatedDuration} seconds\n\n";
$metrics = $this->warmingService->warmAll($force);
$this->displayMetrics($metrics);
return $metrics->totalItemsFailed > 0 ? ExitCode::ERROR : ExitCode::SUCCESS;
}
private function warmStrategy(string $strategyName): int
{
echo "🔥 Warming strategy: {$strategyName}\n\n";
try {
$result = $this->warmingService->warmStrategy($strategyName);
echo "Strategy: {$result->strategyName}\n";
echo "Items warmed: {$result->itemsWarmed}\n";
echo "Items failed: {$result->itemsFailed}\n";
echo "Duration: " . round($result->durationSeconds, 3) . "s\n";
echo "Memory used: " . round($result->getMemoryUsedMB(), 2) . " MB\n";
echo "Success rate: " . round($result->getSuccessRate() * 100, 2) . "%\n";
if (!empty($result->errors)) {
echo "\n⚠️ Errors:\n";
foreach ($result->errors as $error) {
echo " - " . ($error['item'] ?? 'unknown') . ": " . $error['error'] . "\n";
}
}
return $result->isSuccess() ? ExitCode::SUCCESS : ExitCode::ERROR;
} catch (\InvalidArgumentException $e) {
echo "❌ Error: {$e->getMessage()}\n\n";
echo "Use --list to see available strategies.\n";
return ExitCode::ERROR;
}
}
private function warmByPriority(string $priorityLevel): int
{
$priority = $this->parsePriority($priorityLevel);
if ($priority === null) {
echo "❌ Invalid priority level: {$priorityLevel}\n";
echo "Valid values: critical, high, medium, low, background\n";
return ExitCode::ERROR;
}
echo "🔥 Warming strategies with priority >= {$priorityLevel} ({$priority})\n\n";
$metrics = $this->warmingService->warmByPriority($priority);
$this->displayMetrics($metrics);
return $metrics->totalItemsFailed > 0 ? ExitCode::ERROR : ExitCode::SUCCESS;
}
private function listStrategies(): int
{
echo "📋 Available Cache Warming Strategies:\n\n";
$strategies = $this->warmingService->getStrategies();
if (empty($strategies)) {
echo "No strategies registered.\n";
return ExitCode::SUCCESS;
}
foreach ($strategies as $strategy) {
$shouldRun = $strategy['should_run'] ? '✅' : '⏸️';
echo "{$shouldRun} {$strategy['name']}\n";
echo " Priority: {$strategy['priority']}\n";
echo " Estimated duration: {$strategy['estimated_duration']}s\n";
echo " Should run: " . ($strategy['should_run'] ? 'yes' : 'no') . "\n\n";
}
$totalDuration = $this->warmingService->getEstimatedTotalDuration();
echo "⏱️ Total estimated duration: {$totalDuration} seconds\n";
return ExitCode::SUCCESS;
}
private function displayMetrics($metrics): void
{
echo "\n📊 Warmup Results:\n";
echo "==================\n";
echo "Strategies executed: {$metrics->totalStrategiesExecuted}\n";
echo "Items warmed: {$metrics->totalItemsWarmed}\n";
echo "Items failed: {$metrics->totalItemsFailed}\n";
echo "Duration: " . round($metrics->totalDurationSeconds, 3) . "s\n";
echo "Memory used: " . round($metrics->getTotalMemoryUsedMB(), 2) . " MB\n";
echo "Success rate: " . round($metrics->getOverallSuccessRate() * 100, 2) . "%\n";
echo "Items/second: " . round($metrics->getAverageItemsPerSecond(), 2) . "\n\n";
if ($metrics->totalStrategiesExecuted > 0) {
echo "📋 Strategy Details:\n";
foreach ($metrics->strategyResults as $result) {
$icon = $result['is_success'] ? '✅' : '❌';
echo "{$icon} {$result['strategy_name']}: {$result['items_warmed']} items, " .
"{$result['duration_seconds']}s\n";
}
}
}
private function parsePriority(string $level): ?int
{
return match (strtolower($level)) {
'critical' => WarmupPriority::CRITICAL->value,
'high' => WarmupPriority::HIGH->value,
'medium' => WarmupPriority::MEDIUM->value,
'low' => WarmupPriority::LOW->value,
'background' => WarmupPriority::BACKGROUND->value,
default => null
};
}
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Framework\Cache\Warming;
use App\Framework\Cache\Warming\ValueObjects\WarmupPriority;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
/**
* Scheduled job for automatic cache warmup
*
* This job can be registered with the Scheduler to run at regular intervals:
*
* Example:
* $scheduler->schedule(
* 'cache-warmup-critical',
* CronSchedule::fromExpression('0 * * * *'), // Every hour
* fn() => $this->scheduledWarmupJob->warmCriticalPaths()
* );
*/
final readonly class ScheduledWarmupJob
{
public function __construct(
private CacheWarmingService $warmingService,
private Logger $logger
) {}
/**
* Warm only critical paths (fast, essential data)
*
* Recommended schedule: Every hour or on deployment
*/
public function warmCriticalPaths(): array
{
$this->logger->info(
'Starting scheduled warmup: critical paths',
LogContext::empty()
);
$metrics = $this->warmingService->warmByPriority(WarmupPriority::CRITICAL->value);
$this->logger->info(
'Scheduled warmup completed',
LogContext::withData($metrics->toArray())
);
return $metrics->toArray();
}
/**
* Warm high-priority caches (frequent data, slower than critical)
*
* Recommended schedule: Every 6 hours or on deployment
*/
public function warmHighPriority(): array
{
$this->logger->info(
'Starting scheduled warmup: high priority',
LogContext::empty()
);
$metrics = $this->warmingService->warmByPriority(WarmupPriority::HIGH->value);
$this->logger->info(
'Scheduled warmup completed',
LogContext::withData($metrics->toArray())
);
return $metrics->toArray();
}
/**
* Warm all caches including predictive (slow, comprehensive)
*
* Recommended schedule: Daily during off-peak hours (e.g., 3 AM)
*/
public function warmAllCaches(): array
{
$this->logger->info(
'Starting scheduled warmup: all caches',
LogContext::empty()
);
$metrics = $this->warmingService->warmAll(force: false);
$this->logger->info(
'Scheduled warmup completed',
LogContext::withData($metrics->toArray())
);
return $metrics->toArray();
}
/**
* On-demand warmup triggered by specific events
*
* Example: After deployment, configuration changes, or cache clear
*/
public function warmOnDemand(string $reason = 'manual trigger'): array
{
$this->logger->info(
'Starting on-demand warmup',
LogContext::withData(['reason' => $reason])
);
// Force warmup even if shouldRun() returns false
$metrics = $this->warmingService->warmAll(force: true);
$this->logger->info(
'On-demand warmup completed',
LogContext::withData($metrics->toArray())
);
return $metrics->toArray();
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Framework\Cache\Warming\Strategies;
use App\Framework\Cache\Cache;
use App\Framework\Cache\Warming\WarmupStrategy;
use App\Framework\Cache\Warming\ValueObjects\WarmupResult;
/**
* Base class for warmup strategies with common functionality
*/
abstract readonly class BaseWarmupStrategy implements WarmupStrategy
{
public function __construct(
protected Cache $cache
) {}
abstract protected function getItemsToWarm(): array;
public function warmup(): WarmupResult
{
$startTime = microtime(true);
$startMemory = memory_get_usage();
$itemsWarmed = 0;
$itemsFailed = 0;
$errors = [];
$items = $this->getItemsToWarm();
foreach ($items as $item) {
try {
$this->warmItem($item);
$itemsWarmed++;
} catch (\Throwable $e) {
$itemsFailed++;
$errors[] = [
'item' => $this->getItemIdentifier($item),
'error' => $e->getMessage()
];
}
}
$duration = microtime(true) - $startTime;
$memoryUsed = memory_get_usage() - $startMemory;
return new WarmupResult(
strategyName: $this->getName(),
itemsWarmed: $itemsWarmed,
itemsFailed: $itemsFailed,
durationSeconds: $duration,
memoryUsedBytes: max(0, $memoryUsed),
errors: $errors,
metadata: $this->getMetadata()
);
}
abstract protected function warmItem(mixed $item): void;
protected function getItemIdentifier(mixed $item): string
{
if (is_array($item) && isset($item['key'])) {
return (string) $item['key'];
}
return is_string($item) ? $item : 'unknown';
}
protected function getMetadata(): array
{
return [];
}
public function shouldRun(): bool
{
return true;
}
public function getEstimatedDuration(): int
{
return count($this->getItemsToWarm());
}
}

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Framework\Cache\Warming\Strategies;
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheKey;
use App\Framework\Cache\CacheItem;
use App\Framework\Cache\Warming\ValueObjects\WarmupPriority;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Router\CompiledRoutes;
use App\Framework\Config\Environment;
/**
* Warms critical application paths that must be cached for optimal performance
*
* This strategy loads:
* - Compiled routes
* - Framework configuration
* - Environment variables
* - Common application settings
*/
final readonly class CriticalPathWarmingStrategy extends BaseWarmupStrategy
{
public function __construct(
Cache $cache,
private CompiledRoutes $compiledRoutes,
private Environment $environment
) {
parent::__construct($cache);
}
public function getName(): string
{
return 'critical_path';
}
public function getPriority(): int
{
return WarmupPriority::CRITICAL->value;
}
protected function getItemsToWarm(): array
{
return [
[
'key' => 'routes_static',
'loader' => fn() => $this->compiledRoutes->getStaticRoutes(),
'ttl' => Duration::fromDays(7)
],
[
'key' => 'routes_dynamic',
'loader' => fn() => $this->compiledRoutes->getDynamicRoutes(),
'ttl' => Duration::fromDays(7)
],
[
'key' => 'framework_config',
'loader' => fn() => $this->loadFrameworkConfig(),
'ttl' => Duration::fromHours(24)
],
[
'key' => 'env_variables',
'loader' => fn() => $this->loadEnvironmentVariables(),
'ttl' => Duration::fromHours(24)
]
];
}
protected function warmItem(mixed $item): void
{
if (!is_array($item) || !isset($item['key'], $item['loader'], $item['ttl'])) {
throw new \InvalidArgumentException('Invalid warmup item structure');
}
$key = CacheKey::fromString($item['key']);
$data = $item['loader']();
$ttl = $item['ttl'];
$cacheItem = CacheItem::forSetting(
key: $key,
value: $data,
ttl: $ttl
);
$this->cache->set($cacheItem);
}
private function loadFrameworkConfig(): array
{
return [
'cache_enabled' => $this->environment->getBool('CACHE_ENABLED', true),
'debug_mode' => $this->environment->getBool('APP_DEBUG', false),
'app_env' => $this->environment->get('APP_ENV', 'production'),
'session_lifetime' => $this->environment->getInt('SESSION_LIFETIME', 7200),
'cache_driver' => $this->environment->get('CACHE_DRIVER', 'file')
];
}
private function loadEnvironmentVariables(): array
{
// Only cache non-sensitive environment variables
return [
'app_name' => $this->environment->get('APP_NAME', 'Framework'),
'app_url' => $this->environment->get('APP_URL', 'http://localhost'),
'app_timezone' => $this->environment->get('APP_TIMEZONE', 'UTC'),
'cache_prefix' => $this->environment->get('CACHE_PREFIX', 'app_')
];
}
protected function getMetadata(): array
{
return [
'routes_count' => count($this->compiledRoutes->getStaticRoutes()) +
count($this->compiledRoutes->getDynamicRoutes()),
'config_keys' => 5,
'env_vars' => 4
];
}
public function getEstimatedDuration(): int
{
return 2; // Critical path should be very fast (2 seconds)
}
}

View File

@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace App\Framework\Cache\Warming\Strategies;
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheKey;
use App\Framework\Cache\CacheItem;
use App\Framework\Cache\Warming\ValueObjects\WarmupPriority;
use App\Framework\Core\ValueObjects\Duration;
/**
* Predictive warming based on access patterns and historical data
*
* This strategy analyzes cache hit/miss patterns and proactively warms
* cache entries that are likely to be accessed soon based on:
* - Historical access patterns
* - Time of day patterns
* - Seasonal patterns
* - User behavior patterns
*/
final readonly class PredictiveWarmingStrategy extends BaseWarmupStrategy
{
private const ACCESS_PATTERN_CACHE_KEY = 'warmup_access_patterns';
private const MIN_ACCESS_COUNT = 5; // Minimum accesses to consider for warmup
private const PREDICTION_THRESHOLD = 0.7; // 70% probability threshold
public function __construct(
Cache $cache
) {
parent::__construct($cache);
}
public function getName(): string
{
return 'predictive';
}
public function getPriority(): int
{
return WarmupPriority::BACKGROUND->value;
}
protected function getItemsToWarm(): array
{
$accessPatterns = $this->loadAccessPatterns();
$predictions = $this->generatePredictions($accessPatterns);
$items = [];
foreach ($predictions as $prediction) {
if ($prediction['probability'] >= self::PREDICTION_THRESHOLD) {
$items[] = [
'key' => $prediction['cache_key'],
'loader' => $prediction['loader'],
'ttl' => $prediction['ttl'],
'probability' => $prediction['probability']
];
}
}
return $items;
}
protected function warmItem(mixed $item): void
{
if (!is_array($item) || !isset($item['key'], $item['loader'], $item['ttl'])) {
throw new \InvalidArgumentException('Invalid warmup item structure');
}
$key = CacheKey::fromString($item['key']);
// Check if item is already in cache (don't override fresh data)
if ($this->cache->has($key)) {
return;
}
$data = $item['loader']();
$ttl = $item['ttl'];
$cacheItem = CacheItem::forSetting(
key: $key,
value: $data,
ttl: $ttl
);
$this->cache->set($cacheItem);
}
private function loadAccessPatterns(): array
{
$key = CacheKey::fromString(self::ACCESS_PATTERN_CACHE_KEY);
$result = $this->cache->get($key);
if ($result->isHit()) {
return $result->value;
}
return [];
}
private function generatePredictions(array $accessPatterns): array
{
$currentHour = (int) date('H');
$currentDayOfWeek = (int) date('N'); // 1 (Monday) to 7 (Sunday)
$predictions = [];
foreach ($accessPatterns as $pattern) {
$probability = $this->calculateProbability(
pattern: $pattern,
currentHour: $currentHour,
currentDayOfWeek: $currentDayOfWeek
);
if ($probability >= self::PREDICTION_THRESHOLD) {
$predictions[] = [
'cache_key' => $pattern['cache_key'],
'loader' => $pattern['loader'] ?? fn() => null,
'ttl' => $pattern['ttl'] ?? Duration::fromHours(1),
'probability' => $probability,
'reason' => $this->getPredictionReason($pattern, $currentHour, $currentDayOfWeek)
];
}
}
// Sort by probability (highest first)
usort($predictions, fn($a, $b) => $b['probability'] <=> $a['probability']);
// Limit to top 50 predictions to avoid overwhelming the cache
return array_slice($predictions, 0, 50);
}
private function calculateProbability(array $pattern, int $currentHour, int $currentDayOfWeek): float
{
$accessCount = $pattern['access_count'] ?? 0;
if ($accessCount < self::MIN_ACCESS_COUNT) {
return 0.0;
}
$hourlyPattern = $pattern['hourly_distribution'] ?? [];
$dailyPattern = $pattern['daily_distribution'] ?? [];
// Base probability from access frequency
$baseProbability = min($accessCount / 100, 1.0);
// Hour-based adjustment
$hourlyWeight = $hourlyPattern[$currentHour] ?? 0.0;
// Day-based adjustment
$dailyWeight = $dailyPattern[$currentDayOfWeek] ?? 0.0;
// Combined probability
$probability = $baseProbability * 0.4 + $hourlyWeight * 0.4 + $dailyWeight * 0.2;
return min($probability, 1.0);
}
private function getPredictionReason(array $pattern, int $currentHour, int $currentDayOfWeek): string
{
$reasons = [];
if (($pattern['access_count'] ?? 0) >= 50) {
$reasons[] = 'high access frequency';
}
$hourlyPattern = $pattern['hourly_distribution'] ?? [];
if (($hourlyPattern[$currentHour] ?? 0.0) > 0.7) {
$reasons[] = "typically accessed at hour {$currentHour}";
}
$dailyPattern = $pattern['daily_distribution'] ?? [];
$dayName = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][$currentDayOfWeek - 1];
if (($dailyPattern[$currentDayOfWeek] ?? 0.0) > 0.7) {
$reasons[] = "typically accessed on {$dayName}";
}
return !empty($reasons) ? implode(', ', $reasons) : 'access pattern detected';
}
protected function getMetadata(): array
{
$accessPatterns = $this->loadAccessPatterns();
$predictions = $this->generatePredictions($accessPatterns);
return [
'total_patterns' => count($accessPatterns),
'predictions_generated' => count($predictions),
'top_probability' => !empty($predictions) ? round($predictions[0]['probability'], 2) : 0.0
];
}
public function shouldRun(): bool
{
// Only run predictive warming if we have enough historical data
$accessPatterns = $this->loadAccessPatterns();
return count($accessPatterns) >= 10;
}
public function getEstimatedDuration(): int
{
$predictions = $this->generatePredictions($this->loadAccessPatterns());
return max(count($predictions) * 2, 10); // 2 seconds per prediction, min 10 seconds
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Framework\Cache\Warming\ValueObjects;
/**
* Aggregated metrics for cache warmup operations
*/
final readonly class WarmupMetrics
{
public function __construct(
public int $totalStrategiesExecuted,
public int $totalItemsWarmed,
public int $totalItemsFailed,
public float $totalDurationSeconds,
public int $totalMemoryUsedBytes,
public array $strategyResults = [],
public \DateTimeImmutable $executedAt = new \DateTimeImmutable()
) {}
public static function fromResults(array $results): self
{
$totalItemsWarmed = 0;
$totalItemsFailed = 0;
$totalDuration = 0.0;
$totalMemory = 0;
foreach ($results as $result) {
if (!$result instanceof WarmupResult) {
continue;
}
$totalItemsWarmed += $result->itemsWarmed;
$totalItemsFailed += $result->itemsFailed;
$totalDuration += $result->durationSeconds;
$totalMemory += $result->memoryUsedBytes;
}
return new self(
totalStrategiesExecuted: count($results),
totalItemsWarmed: $totalItemsWarmed,
totalItemsFailed: $totalItemsFailed,
totalDurationSeconds: $totalDuration,
totalMemoryUsedBytes: $totalMemory,
strategyResults: array_map(fn($r) => $r->toArray(), $results)
);
}
public function getOverallSuccessRate(): float
{
$total = $this->totalItemsWarmed + $this->totalItemsFailed;
if ($total === 0) {
return 1.0;
}
return $this->totalItemsWarmed / $total;
}
public function getAverageItemsPerSecond(): float
{
if ($this->totalDurationSeconds === 0.0) {
return 0.0;
}
return $this->totalItemsWarmed / $this->totalDurationSeconds;
}
public function getTotalMemoryUsedMB(): float
{
return $this->totalMemoryUsedBytes / 1024 / 1024;
}
public function toArray(): array
{
return [
'executed_at' => $this->executedAt->format('Y-m-d H:i:s'),
'total_strategies_executed' => $this->totalStrategiesExecuted,
'total_items_warmed' => $this->totalItemsWarmed,
'total_items_failed' => $this->totalItemsFailed,
'total_duration_seconds' => round($this->totalDurationSeconds, 3),
'total_memory_used_mb' => round($this->getTotalMemoryUsedMB(), 2),
'overall_success_rate' => round($this->getOverallSuccessRate() * 100, 2),
'average_items_per_second' => round($this->getAverageItemsPerSecond(), 2),
'strategy_results' => $this->strategyResults
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Framework\Cache\Warming\ValueObjects;
/**
* Priority levels for cache warmup strategies
*/
enum WarmupPriority: int
{
case CRITICAL = 1000; // Routes, config - must warm first
case HIGH = 500; // Frequent database queries
case MEDIUM = 250; // Session data, templates
case LOW = 100; // Less frequently accessed data
case BACKGROUND = 0; // Nice-to-have, run when idle
public function getDescription(): string
{
return match ($this) {
self::CRITICAL => 'Critical application data (routes, config)',
self::HIGH => 'Frequently accessed data (popular queries)',
self::MEDIUM => 'Moderately accessed data (templates, session)',
self::LOW => 'Infrequently accessed data',
self::BACKGROUND => 'Background optimization (predictive)',
};
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Framework\Cache\Warming\ValueObjects;
/**
* Result of a cache warmup operation
*/
final readonly class WarmupResult
{
public function __construct(
public string $strategyName,
public int $itemsWarmed,
public int $itemsFailed,
public float $durationSeconds,
public int $memoryUsedBytes,
public array $errors = [],
public array $metadata = []
) {
if ($itemsWarmed < 0) {
throw new \InvalidArgumentException('Items warmed cannot be negative');
}
if ($itemsFailed < 0) {
throw new \InvalidArgumentException('Items failed cannot be negative');
}
if ($durationSeconds < 0.0) {
throw new \InvalidArgumentException('Duration cannot be negative');
}
if ($memoryUsedBytes < 0) {
throw new \InvalidArgumentException('Memory used cannot be negative');
}
}
public function isSuccess(): bool
{
return $this->itemsFailed === 0;
}
public function getSuccessRate(): float
{
$total = $this->itemsWarmed + $this->itemsFailed;
if ($total === 0) {
return 1.0;
}
return $this->itemsWarmed / $total;
}
public function getItemsPerSecond(): float
{
if ($this->durationSeconds === 0.0) {
return 0.0;
}
return $this->itemsWarmed / $this->durationSeconds;
}
public function getMemoryUsedMB(): float
{
return $this->memoryUsedBytes / 1024 / 1024;
}
public function toArray(): array
{
return [
'strategy_name' => $this->strategyName,
'items_warmed' => $this->itemsWarmed,
'items_failed' => $this->itemsFailed,
'duration_seconds' => round($this->durationSeconds, 3),
'memory_used_mb' => round($this->getMemoryUsedMB(), 2),
'success_rate' => round($this->getSuccessRate() * 100, 2),
'items_per_second' => round($this->getItemsPerSecond(), 2),
'is_success' => $this->isSuccess(),
'errors' => $this->errors,
'metadata' => $this->metadata
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Framework\Cache\Warming;
use App\Framework\Cache\Warming\ValueObjects\WarmupResult;
/**
* Interface for cache warming strategies
*
* Strategies determine which cache entries to warm up and in what order.
*/
interface WarmupStrategy
{
/**
* Get the strategy name for identification
*/
public function getName(): string;
/**
* Get the priority of this strategy (higher = executed first)
*/
public function getPriority(): int;
/**
* Execute the warmup strategy
*
* @return WarmupResult Result of the warmup operation
*/
public function warmup(): WarmupResult;
/**
* Check if this strategy should run based on current conditions
*/
public function shouldRun(): bool;
/**
* Get estimated time to complete warmup in seconds
*/
public function getEstimatedDuration(): int;
}

View File

@@ -25,8 +25,7 @@ final class DefaultCommandBus implements CommandBus
PerformanceMonitoringMiddleware::class,
DatabaseTransactionMiddleware::class,
],
) {
}
) {}
public function dispatch(object $command): mixed
{

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Framework\Config;
use App\Framework\Encryption\EncryptionFactory;
use App\Framework\Filesystem\FilePath;
use App\Framework\Filesystem\ValueObjects\FilePath;
use App\Framework\Random\RandomGenerator;
/**

View File

@@ -54,6 +54,7 @@ enum EnvKey: string
case REDIS_HOST = 'REDIS_HOST';
case REDIS_PORT = 'REDIS_PORT';
case REDIS_PASSWORD = 'REDIS_PASSWORD';
case REDIS_PREFIX = 'REDIS_PREFIX';
// Vault Configuration
case VAULT_ENCRYPTION_KEY = 'VAULT_ENCRYPTION_KEY';

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Framework\Config;
use App\Framework\Config\Exceptions\RequiredEnvironmentVariableException;
use App\Framework\Filesystem\FilePath;
use App\Framework\Filesystem\ValueObjects\FilePath;
use BackedEnum;
final readonly class Environment

View File

@@ -7,36 +7,47 @@ namespace App\Framework\Console\Analytics\Migrations;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Migration\MigrationVersion;
use App\Framework\Database\Schema\Blueprint;
use App\Framework\Database\Schema\Schema;
final readonly class CreateCommandUsageAnalyticsTable implements Migration
{
public function up(ConnectionInterface $connection): void
{
$sql = "CREATE TABLE IF NOT EXISTS command_usage_analytics (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
command_name VARCHAR(255) NOT NULL,
executed_at DATETIME NOT NULL,
execution_time_ms DECIMAL(10, 3) NOT NULL,
exit_code TINYINT NOT NULL DEFAULT 0,
argument_count INT NOT NULL DEFAULT 0,
user_id VARCHAR(255) NULL,
metadata JSON NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_command_name (command_name),
INDEX idx_executed_at (executed_at),
INDEX idx_user_id (user_id),
INDEX idx_command_executed (command_name, executed_at),
INDEX idx_command_exit (command_name, exit_code),
INDEX idx_executed_exit (executed_at, exit_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
$schema = new Schema($connection);
$connection->execute($sql);
$schema->createIfNotExists('command_usage_analytics', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('command_name', 255);
$table->dateTime('executed_at');
$table->decimal('execution_time_ms', 10, 3);
$table->tinyInteger('exit_code')->default(0);
$table->integer('argument_count')->default(0);
$table->string('user_id', 255)->nullable();
$table->json('metadata')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent();
$table->index('command_name', 'idx_command_name');
$table->index('executed_at', 'idx_executed_at');
$table->index('user_id', 'idx_user_id');
$table->index(['command_name', 'executed_at'], 'idx_command_executed');
$table->index(['command_name', 'exit_code'], 'idx_command_exit');
$table->index(['executed_at', 'exit_code'], 'idx_executed_exit');
$table->engine('InnoDB');
$table->charset('utf8mb4');
$table->collation('utf8mb4_unicode_ci');
});
$schema->execute();
}
public function down(ConnectionInterface $connection): void
{
$connection->execute("DROP TABLE IF EXISTS command_usage_analytics");
$schema = new Schema($connection);
$schema->dropIfExists('command_usage_analytics');
$schema->execute();
}
public function getVersion(): MigrationVersion

View File

@@ -58,7 +58,7 @@ final readonly class ArgumentDefinition
*/
public static function flag(string $name, ?string $shortName = null, string $description = ''): self
{
return new self($name, ArgumentType::BOOLEAN, shortName: $shortName, description: $description);
return new self($name, ArgumentType::BOOLEAN, description: $description, shortName: $shortName);
}
/**

View File

@@ -83,17 +83,21 @@ final readonly class ArgumentParser
// Handle --option=value
if (str_contains($optionPart, '=')) {
[$name, $value] = explode('=', $optionPart, 2);
$parsed['options'][$name] = $this->parseValue($name, $value);
$camelCaseName = $this->kebabToCamelCase($name);
$parsed['options'][$camelCaseName] = $this->parseValue($camelCaseName, $value);
return $index + 1;
}
// Convert kebab-case to camelCase for definition lookup
$camelCaseName = $this->kebabToCamelCase($optionPart);
// Handle --option value or --flag
$definition = $this->findDefinitionByName($optionPart);
$definition = $this->findDefinitionByName($camelCaseName);
if ($definition && $definition->type === ArgumentType::BOOLEAN) {
// Boolean flag
$parsed['flags'][$optionPart] = true;
// Boolean flag - use camelCase name
$parsed['flags'][$camelCaseName] = true;
return $index + 1;
}
@@ -104,16 +108,25 @@ final readonly class ArgumentParser
throw new \InvalidArgumentException("Option '--{$optionPart}' requires a value");
}
// Optional option without value - treat as flag
$parsed['flags'][$optionPart] = true;
$parsed['flags'][$camelCaseName] = true;
return $index + 1;
}
$parsed['options'][$optionPart] = $this->parseValue($optionPart, $arguments[$index + 1]);
$parsed['options'][$camelCaseName] = $this->parseValue($camelCaseName, $arguments[$index + 1]);
return $index + 2;
}
/**
* Convert kebab-case to camelCase
* Example: dry-run -> dryRun
*/
private function kebabToCamelCase(string $kebab): string
{
return lcfirst(str_replace('-', '', ucwords($kebab, '-')));
}
/**
* Parse short option (-o or -o value or -abc)
*/
@@ -325,54 +338,3 @@ final readonly class ArgumentParser
return new ArgumentParserBuilder();
}
}
/**
* Fluent builder for ArgumentParser
*/
final class ArgumentParserBuilder
{
/** @var ArgumentDefinition[] */
private array $definitions = [];
public function addArgument(ArgumentDefinition $definition): self
{
$this->definitions[$definition->name] = $definition;
return $this;
}
public function requiredString(string $name, string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::required($name, $description));
}
public function optionalString(string $name, string $default = '', string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::optional($name, $default, $description));
}
public function flag(string $name, ?string $shortName = null, string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::flag($name, $shortName, $description));
}
public function email(string $name, bool $required = true, string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::email($name, $required, $description));
}
public function integer(string $name, bool $required = false, ?int $default = null, string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::integer($name, $required, $default, $description));
}
public function choice(string $name, array $choices, bool $required = false, ?string $default = null, string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::choice($name, $choices, $required, $default, $description));
}
public function build(): ArgumentParser
{
return new ArgumentParser($this->definitions);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Framework\Console;
/**
* Fluent builder for ArgumentParser
*/
final class ArgumentParserBuilder
{
/** @var ArgumentDefinition[] */
private array $definitions = [];
public function addArgument(ArgumentDefinition $definition): self
{
$this->definitions[$definition->name] = $definition;
return $this;
}
public function requiredString(string $name, string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::required($name, $description));
}
public function optionalString(string $name, string $default = '', string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::optional($name, $default, $description));
}
public function flag(string $name, ?string $shortName = null, string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::flag($name, $shortName, $description));
}
public function email(string $name, bool $required = true, string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::email($name, $required, $description));
}
public function integer(string $name, bool $required = false, ?int $default = null, string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::integer($name, $required, $default, $description));
}
public function choice(string $name, array $choices, bool $required = false, ?string $default = null, string $description = ''): self
{
return $this->addArgument(ArgumentDefinition::choice($name, $choices, $required, $default, $description));
}
public function build(): ArgumentParser
{
return new ArgumentParser($this->definitions);
}
}

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Filesystem\FilePath;
use App\Framework\Filesystem\ValueObjects\FilePath;
/**
* Command history and favorites management for console TUI

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\Core\ConsoleErrorCode;
use App\Framework\Exception\FrameworkException;
use ArrayIterator;
use Countable;
@@ -27,7 +27,7 @@ final readonly class CommandList implements IteratorAggregate, Countable
foreach ($commands as $command) {
if (isset($commandMap[$command->name])) {
throw FrameworkException::create(
ErrorCode::CON_INVALID_COMMAND_STRUCTURE,
ConsoleErrorCode::INVALID_COMMAND_STRUCTURE,
"Duplicate command name '{$command->name}'"
)->withData(['command_name' => $command->name]);
}
@@ -47,7 +47,7 @@ final readonly class CommandList implements IteratorAggregate, Countable
{
if ($this->has($command->name)) {
throw FrameworkException::create(
ErrorCode::CON_INVALID_COMMAND_STRUCTURE,
ConsoleErrorCode::INVALID_COMMAND_STRUCTURE,
"Command '{$command->name}' already exists"
)->withData(['command_name' => $command->name]);
}
@@ -67,7 +67,7 @@ final readonly class CommandList implements IteratorAggregate, Countable
{
if (! $this->has($name)) {
throw FrameworkException::create(
ErrorCode::CON_COMMAND_NOT_FOUND,
ConsoleErrorCode::COMMAND_NOT_FOUND,
"Command '{$name}' not found"
)->withData(['command_name' => $name]);
}

View File

@@ -5,7 +5,8 @@ declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Core\ValueObjects\Email;
use App\Framework\Core\ValueObjects\Url;
use App\Framework\Http\Url\Url;
use App\Framework\Http\Url\UrlFactory;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionParameter;
@@ -26,9 +27,13 @@ final readonly class CommandParameterResolver
* @param string[] $rawArguments
* @return array<int, mixed>
*/
public function resolveParameters(ReflectionMethod $method, array $rawArguments): array
{
// Generate argument definitions from method signature
public function resolveParameters(
ReflectionMethod $method,
array $rawArguments,
?ConsoleInput $consoleInput = null,
?ConsoleOutputInterface $consoleOutput = null
): array {
// Generate argument definitions from method signature (excluding framework types)
$definitions = $this->signatureAnalyzer->generateArgumentDefinitions($method);
// Create parser with auto-generated definitions
@@ -41,6 +46,14 @@ final readonly class CommandParameterResolver
$resolvedParams = [];
foreach ($method->getParameters() as $param) {
// Check if this is a framework-injected parameter
if ($this->isFrameworkParameter($param)) {
$frameworkValue = $this->resolveFrameworkParameter($param, $consoleInput, $consoleOutput);
$resolvedParams[] = $frameworkValue;
continue;
}
// Otherwise resolve from user arguments
$value = $this->resolveParameterValue($param, $parsedArgs);
$resolvedParams[] = $value;
}
@@ -48,6 +61,77 @@ final readonly class CommandParameterResolver
return $resolvedParams;
}
/**
* Check if parameter is a framework-provided type
*/
private function isFrameworkParameter(ReflectionParameter $param): bool
{
$paramType = $param->getType();
if (! ($paramType instanceof ReflectionNamedType)) {
return false;
}
$typeName = $paramType->getName();
$frameworkTypes = [
'App\\Framework\\Console\\ConsoleInput',
'App\\Framework\\Console\\ConsoleInputInterface',
'App\\Framework\\Console\\ConsoleOutputInterface',
'App\\Framework\\Console\\ConsoleOutput',
'ConsoleInput',
'ConsoleInputInterface',
'ConsoleOutputInterface',
'ConsoleOutput',
];
return in_array($typeName, $frameworkTypes, true);
}
/**
* Resolve framework-provided parameters (ConsoleInput, ConsoleOutput)
*/
private function resolveFrameworkParameter(
ReflectionParameter $param,
?ConsoleInput $consoleInput,
?ConsoleOutputInterface $consoleOutput
): mixed {
$paramType = $param->getType();
if (! ($paramType instanceof ReflectionNamedType)) {
throw new \LogicException("Framework parameter must have a named type");
}
$typeName = $paramType->getName();
// Check for ConsoleInput
if (in_array($typeName, ['App\\Framework\\Console\\ConsoleInput', 'ConsoleInput'], true)) {
if ($consoleInput === null && ! $paramType->allowsNull()) {
throw new \InvalidArgumentException(
"ConsoleInput is required but not provided for parameter '{$param->getName()}'"
);
}
return $consoleInput;
}
// Check for ConsoleOutput/ConsoleOutputInterface
if (in_array($typeName, [
'App\\Framework\\Console\\ConsoleOutputInterface',
'App\\Framework\\Console\\ConsoleOutput',
'ConsoleOutputInterface',
'ConsoleOutput'
], true)) {
if ($consoleOutput === null && ! $paramType->allowsNull()) {
throw new \InvalidArgumentException(
"ConsoleOutput is required but not provided for parameter '{$param->getName()}'"
);
}
return $consoleOutput;
}
throw new \LogicException("Unknown framework parameter type: {$typeName}");
}
/**
* Resolve individual parameter value
*/
@@ -61,6 +145,11 @@ final readonly class CommandParameterResolver
// Handle null/missing values
if ($rawValue === null || $rawValue === '') {
// Special case: nullable bool (?bool) - missing flag = null (not false!)
if ($this->isNullableBoolParameter($param)) {
return null;
}
if ($param->isDefaultValueAvailable()) {
return $param->getDefaultValue();
}
@@ -76,6 +165,41 @@ final readonly class CommandParameterResolver
return $this->convertToParameterType($rawValue, $paramType, $paramName);
}
/**
* Check if parameter is nullable bool (?bool)
*/
private function isNullableBoolParameter(ReflectionParameter $param): bool
{
$type = $param->getType();
if (!($type instanceof \ReflectionUnionType)) {
return false;
}
$types = $type->getTypes();
if (count($types) !== 2) {
return false;
}
$hasNull = false;
$hasBool = false;
foreach ($types as $unionType) {
if (!($unionType instanceof \ReflectionNamedType)) {
return false;
}
$name = strtolower($unionType->getName());
if ($name === 'null') {
$hasNull = true;
} elseif ($name === 'bool' || $name === 'boolean') {
$hasBool = true;
}
}
return $hasNull && $hasBool;
}
/**
* Convert value to the appropriate parameter type
*/
@@ -224,7 +348,7 @@ final readonly class CommandParameterResolver
// Handle framework value objects
return match ($typeName) {
Email::class => new Email($this->convertToString($value)),
Url::class => new Url($this->convertToString($value)),
Url::class => UrlFactory::parse($this->convertToString($value)),
default => $this->attemptGenericConstruction($value, $typeName, $paramName)
};
}

View File

@@ -9,7 +9,7 @@ use App\Framework\Console\Progress\ProgressMiddleware;
use App\Framework\DI\Container;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\Core\ConsoleErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
@@ -53,7 +53,7 @@ final readonly class CommandRegistry
{
if (! isset($this->discoveredAttributes[$commandName])) {
throw FrameworkException::create(
ErrorCode::CON_COMMAND_NOT_FOUND,
ConsoleErrorCode::COMMAND_NOT_FOUND,
"No discovered attribute found for command '{$commandName}'"
)->withData(['command_name' => $commandName]);
}
@@ -100,7 +100,7 @@ final readonly class CommandRegistry
// Validate command structure
if (! is_object($instance) || ! method_exists($instance, $methodName)) {
throw FrameworkException::create(
ErrorCode::CON_INVALID_COMMAND_STRUCTURE,
ConsoleErrorCode::INVALID_COMMAND_STRUCTURE,
"Invalid command configuration for '{$commandName}'"
)->withData([
'command_name' => $commandName,
@@ -128,7 +128,7 @@ final readonly class CommandRegistry
} catch (Throwable $e) {
throw FrameworkException::create(
ErrorCode::CON_COMMAND_EXECUTION_FAILED,
ConsoleErrorCode::EXECUTION_FAILED,
"Failed to execute command '{$commandName}': {$e->getMessage()}"
)->withData([
'command_name' => $commandName,
@@ -259,23 +259,25 @@ final readonly class CommandRegistry
try {
// Create the actual command execution callback
$commandExecutor = function (ConsoleInput $input, ConsoleOutputInterface $progressAwareOutput) use ($instance, $method, $arguments) {
// Check if method uses the new reflection-based parameter style
if ($this->usesReflectionParameters($method)) {
// Performance tracking: Parameter resolution
$parameterStart = microtime(true);
$resolvedParams = $this->parameterResolver->resolveParameters($method, $arguments);
$parameterDuration = (microtime(true) - $parameterStart) * 1000;
// Performance tracking: Parameter resolution
$parameterStart = microtime(true);
if ($this->performanceCollector) {
$this->performanceCollector->recordParameterResolutionTime($method->getDeclaringClass()->getName(), $parameterDuration);
}
// Resolve parameters with framework injection support
$resolvedParams = $this->parameterResolver->resolveParameters(
$method,
$arguments,
$input, // Inject ConsoleInput
$progressAwareOutput // Inject ConsoleOutput
);
$result = $method->invokeArgs($instance, $resolvedParams);
} else {
// For legacy style, use the progress-aware output
$result = $method->invoke($instance, $input, $progressAwareOutput);
$parameterDuration = (microtime(true) - $parameterStart) * 1000;
if ($this->performanceCollector) {
$this->performanceCollector->recordParameterResolutionTime($method->getDeclaringClass()->getName(), $parameterDuration);
}
$result = $method->invokeArgs($instance, $resolvedParams);
return $this->normalizeCommandResult($result);
};
@@ -295,43 +297,6 @@ final readonly class CommandRegistry
}
}
/**
* Determine if method uses reflection-based parameters
*/
private function usesReflectionParameters(ReflectionMethod $method): bool
{
$parameters = $method->getParameters();
// If no parameters, use simple invocation (no ConsoleInput/Output needed)
if (empty($parameters)) {
return true;
}
// If first parameter is ConsoleInput, it's legacy style
$firstParam = $parameters[0];
$firstParamType = $firstParam->getType();
if ($firstParamType instanceof \ReflectionNamedType) {
$typeName = $firstParamType->getName();
if ($typeName === ConsoleInput::class || $typeName === ConsoleInputInterface::class) {
return false; // Legacy style
}
}
// If method has ConsoleInput or ConsoleOutput in parameters, it's legacy
foreach ($parameters as $param) {
$type = $param->getType();
if ($type instanceof \ReflectionNamedType) {
$typeName = $type->getName();
if (in_array($typeName, [ConsoleInput::class, ConsoleInputInterface::class, ConsoleOutputInterface::class], true)) {
return false;
}
}
}
// All other cases are considered reflection-based
return true;
}
/**
* Generate help for a specific command
@@ -345,14 +310,7 @@ final readonly class CommandRegistry
try {
$reflectionMethod = new ReflectionMethod($className, $methodName);
if ($this->usesReflectionParameters($reflectionMethod)) {
return $this->parameterResolver->generateMethodHelp($reflectionMethod, $commandName);
} else {
// Generate basic help for legacy commands
$command = $this->commandList->get($commandName);
return "Command: {$commandName}\nDescription: {$command->description}\n\nThis command uses legacy parameter style.";
}
return $this->parameterResolver->generateMethodHelp($reflectionMethod, $commandName);
} catch (ReflectionException $e) {
return "Command: {$commandName}\nError generating help: {$e->getMessage()}";

View File

@@ -11,7 +11,7 @@ use App\Framework\Console\ExitCode;
use App\Framework\Console\Input\ConsoleInput;
use App\Framework\Console\Output\ConsoleOutput;
use App\Framework\Development\HotReload\HotReloadServer;
use App\Framework\Filesystem\FilePath;
use App\Framework\Filesystem\ValueObjects\FilePath;
use App\Framework\Filesystem\FileWatcher;
use App\Framework\Http\SseStream;
use Psr\Log\LoggerInterface;

View File

@@ -7,7 +7,10 @@ namespace App\Framework\Console\ErrorRecovery;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\Core\AuthErrorCode;
use App\Framework\Exception\Core\ConsoleErrorCode;
use App\Framework\Exception\Core\DatabaseErrorCode;
use App\Framework\Exception\Core\HttpErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
@@ -115,21 +118,21 @@ final readonly class ConsoleErrorHandler
$errorCode = $exception->getErrorCode();
return match ($errorCode) {
ErrorCode::CON_COMMAND_NOT_FOUND => $this->handleCommandNotFound($command, $output),
ConsoleErrorCode::COMMAND_NOT_FOUND => $this->handleCommandNotFound($command, $output),
ErrorCode::CON_INVALID_ARGUMENTS => $this->handleValidationError(
ConsoleErrorCode::INVALID_ARGUMENT => $this->handleValidationError(
$command,
$exception->getMessage(),
$output
),
ErrorCode::AUTH_UNAUTHORIZED,
ErrorCode::AUTH_INSUFFICIENT_PRIVILEGES => $this->handlePermissionError($command, $output),
AuthErrorCode::UNAUTHORIZED,
AuthErrorCode::INSUFFICIENT_PRIVILEGES => $this->handlePermissionError($command, $output),
ErrorCode::DB_CONNECTION_FAILED,
ErrorCode::DB_QUERY_FAILED => $this->handleDatabaseError($command, $exception, $output),
DatabaseErrorCode::CONNECTION_FAILED,
DatabaseErrorCode::QUERY_FAILED => $this->handleDatabaseError($command, $exception, $output),
ErrorCode::HTTP_RATE_LIMIT_EXCEEDED => $this->handleRateLimitError($command, $exception, $output),
HttpErrorCode::RATE_LIMIT_EXCEEDED => $this->handleRateLimitError($command, $exception, $output),
default => $this->handleGeneralFrameworkError($command, $exception, $output)
};
@@ -188,13 +191,13 @@ final readonly class ConsoleErrorHandler
{
if ($error instanceof FrameworkException) {
return match ($error->getErrorCode()) {
ErrorCode::CON_COMMAND_NOT_FOUND => ExitCode::COMMAND_NOT_FOUND,
ErrorCode::CON_INVALID_ARGUMENTS => ExitCode::INVALID_INPUT,
ErrorCode::AUTH_UNAUTHORIZED,
ErrorCode::AUTH_INSUFFICIENT_PRIVILEGES => ExitCode::PERMISSION_DENIED,
ErrorCode::DB_CONNECTION_FAILED,
ErrorCode::DB_QUERY_FAILED => ExitCode::DATABASE_ERROR,
ErrorCode::HTTP_RATE_LIMIT_EXCEEDED => ExitCode::RATE_LIMITED,
ConsoleErrorCode::COMMAND_NOT_FOUND => ExitCode::COMMAND_NOT_FOUND,
ConsoleErrorCode::INVALID_ARGUMENT => ExitCode::INVALID_INPUT,
AuthErrorCode::UNAUTHORIZED,
AuthErrorCode::INSUFFICIENT_PRIVILEGES => ExitCode::PERMISSION_DENIED,
DatabaseErrorCode::CONNECTION_FAILED,
DatabaseErrorCode::QUERY_FAILED => ExitCode::DATABASE_ERROR,
HttpErrorCode::RATE_LIMIT_EXCEEDED => ExitCode::RATE_LIMITED,
default => ExitCode::GENERAL_ERROR
};
}

View File

@@ -8,7 +8,9 @@ use App\Framework\Console\CommandList;
use App\Framework\Console\CommandRegistry;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\Core\ConsoleErrorCode;
use App\Framework\Exception\Core\DatabaseErrorCode;
use App\Framework\Exception\Core\SystemErrorCode;
use App\Framework\Exception\FrameworkException;
final readonly class ErrorRecoveryService
@@ -120,22 +122,22 @@ final readonly class ErrorRecoveryService
if ($error instanceof FrameworkException) {
$options = match ($error->getErrorCode()) {
ErrorCode::CON_COMMAND_NOT_FOUND => [
ConsoleErrorCode::COMMAND_NOT_FOUND => [
'Check if the command name is spelled correctly',
'Use "help" to see all available commands',
'Check if the command is properly registered',
],
ErrorCode::CON_INVALID_ARGUMENTS => [
ConsoleErrorCode::INVALID_ARGUMENT => [
'Check the command usage with "help {command}"',
'Verify all required arguments are provided',
'Check argument types and formats',
],
ErrorCode::DB_CONNECTION_FAILED => [
DatabaseErrorCode::CONNECTION_FAILED => [
'Check database connection settings',
'Verify database server is running',
'Check network connectivity to database',
],
ErrorCode::SYSTEM_DEPENDENCY_MISSING => [
SystemErrorCode::DEPENDENCY_MISSING => [
'Check if all required dependencies are installed',
'Verify service configuration',
'Check for missing environment variables',

View File

@@ -0,0 +1,258 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Examples;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use App\Framework\Core\ValueObjects\Email;
/**
* Best practices for Console Command parameters
*
* Shows recommended patterns and when to use different parameter styles.
*/
final readonly class ConsoleParameterBestPracticesCommand
{
/**
* ✅ RECOMMENDED: Typed parameters for known arguments
*
* Usage: php console.php best:typed username --max 20
*/
#[ConsoleCommand(
name: 'best:typed',
description: 'Typed parameters example'
)]
public function typedParameters(
ConsoleOutputInterface $output,
string $username,
int $max = 10
): ExitCode {
$output->writeLine("Processing user: {$username}");
$output->writeLine("Maximum results: {$max}");
return ExitCode::SUCCESS;
}
/**
* ✅ RECOMMENDED: Value Objects for validation
*
* Usage: php console.php best:value-objects user@example.com
*/
#[ConsoleCommand(
name: 'best:value-objects',
description: 'Value Objects for automatic validation'
)]
public function valueObjects(
ConsoleOutputInterface $output,
Email $email
): ExitCode {
// Email is already validated by Value Object!
$output->writeLine("Valid email: {$email->value}");
return ExitCode::SUCCESS;
}
/**
* ✅ RECOMMENDED: Nullable bool for optional flags
*
* Usage: php console.php best:flags production (verbose=null)
* php console.php best:flags production --verbose (verbose=true)
*/
#[ConsoleCommand(
name: 'best:flags',
description: 'Optional flags with nullable bool'
)]
public function withFlags(
ConsoleOutputInterface $output,
string $environment,
?bool $verbose = null,
?bool $force = null
): ExitCode {
$output->writeLine("Deploying to: {$environment}");
if ($verbose === true) {
$output->writeLine("Verbose mode enabled");
}
if ($force === true) {
$output->writeLine("Force mode enabled");
}
if ($verbose === null && $force === null) {
$output->writeLine("No flags provided");
}
return ExitCode::SUCCESS;
}
/**
* ✅ RECOMMENDED: Mix typed params with flags
*
* Usage: php console.php best:mixed Alice 25 --verified --active
*/
#[ConsoleCommand(
name: 'best:mixed',
description: 'Mix typed parameters with optional flags'
)]
public function mixedParametersAndFlags(
ConsoleOutputInterface $output,
string $name,
int $age,
?bool $verified = null,
?bool $active = null
): ExitCode {
$output->writeLine("Name: {$name}, Age: {$age}");
$status = [];
if ($verified === true) {
$status[] = 'verified';
}
if ($active === true) {
$status[] = 'active';
}
if (!empty($status)) {
$output->writeLine("Status: " . implode(', ', $status));
}
return ExitCode::SUCCESS;
}
/**
* ✅ RECOMMENDED: No framework params needed
*
* Usage: php console.php best:minimal Alice
*/
#[ConsoleCommand(
name: 'best:minimal',
description: 'Minimal command without ConsoleOutput'
)]
public function minimalCommand(string $name): ExitCode
{
// Silent processing - no output needed
file_put_contents('/tmp/user.txt', $name);
return ExitCode::SUCCESS;
}
/**
* ⚠️ USE SPARINGLY: ConsoleInput for variable args
*
* Only use ConsoleInput when you need:
* - Variable number of arguments
* - Raw command line access
* - Dynamic argument handling
*
* Usage: php console.php best:variable file1.txt file2.txt file3.txt
*/
#[ConsoleCommand(
name: 'best:variable',
description: 'Variable arguments with ConsoleInput'
)]
public function variableArguments(
ConsoleInput $input,
ConsoleOutputInterface $output
): ExitCode {
$files = $input->getArguments();
if (empty($files)) {
$output->error('No files provided');
return ExitCode::FAILURE;
}
foreach ($files as $file) {
$output->writeLine("Processing: {$file}");
}
return ExitCode::SUCCESS;
}
/**
* ❌ AVOID: Manual parsing with ConsoleInput
*
* This is the OLD style - avoid this!
*/
#[ConsoleCommand(
name: 'best:old-style',
description: 'Old style (avoid this!)'
)]
public function oldStyleAvoid(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
// ❌ Manual parsing - no type safety
$name = $input->getArgument('name');
$age = (int) ($input->getArgument('age') ?? 18);
$output->writeLine("Name: {$name}, Age: {$age}");
return ExitCode::SUCCESS;
}
/**
* ✅ BETTER: Typed parameters instead
*
* Prefer this over manual ConsoleInput parsing
*/
#[ConsoleCommand(
name: 'best:new-style',
description: 'New style with typed parameters'
)]
public function newStylePrefer(
ConsoleOutputInterface $output,
string $name,
int $age = 18
): ExitCode {
// ✅ Already typed and validated
$output->writeLine("Name: {$name}, Age: {$age}");
return ExitCode::SUCCESS;
}
/**
* ✅ RECOMMENDED: Complex flags example
*
* Shows different flag patterns
*
* Usage: php console.php best:complex production --dry-run --skip-backup
*/
#[ConsoleCommand(
name: 'best:complex',
description: 'Complex deployment with multiple flags'
)]
public function complexFlags(
ConsoleOutputInterface $output,
string $environment,
?bool $dryRun = null,
?bool $skipBackup = null,
?bool $skipTests = null,
?bool $force = null
): ExitCode {
$output->writeLine("Deploying to: {$environment}");
// Check flags
if ($dryRun === true) {
$output->writeLine("DRY RUN MODE - No actual changes");
}
if ($skipBackup === true) {
$output->writeLine("Skipping backup");
} else {
$output->writeLine("Creating backup...");
}
if ($skipTests === true) {
$output->writeLine("Skipping tests");
} else {
$output->writeLine("Running tests...");
}
if ($force === true && $environment === 'production') {
$output->warning("Force deploying to PRODUCTION!");
}
return ExitCode::SUCCESS;
}
}

View File

@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Examples;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
/**
* Demo command showing flexible parameter support
*
* This command demonstrates that ConsoleInput and ConsoleOutput
* parameters are now optional and can be mixed with other parameters
*/
final readonly class FlexibleParameterDemoCommand
{
/**
* Command without any parameters
*/
#[ConsoleCommand(
name: 'demo:no-params',
description: 'Demo command without any parameters'
)]
public function withoutParameters(): ExitCode
{
echo "This command has no parameters!\n";
return ExitCode::SUCCESS;
}
/**
* Command with only user parameters (no framework types)
*/
#[ConsoleCommand(
name: 'demo:user-params',
description: 'Demo command with only user parameters'
)]
public function withUserParameters(string $name, int $age = 18): ExitCode
{
echo "Name: {$name}\n";
echo "Age: {$age}\n";
return ExitCode::SUCCESS;
}
/**
* Command with ConsoleOutput only
*/
#[ConsoleCommand(
name: 'demo:with-output',
description: 'Demo command with ConsoleOutput parameter'
)]
public function withOutput(ConsoleOutputInterface $output, string $message = 'Hello World'): ExitCode
{
$output->writeLine("Message from ConsoleOutput: {$message}");
return ExitCode::SUCCESS;
}
/**
* Command with ConsoleInput only
*/
#[ConsoleCommand(
name: 'demo:with-input',
description: 'Demo command with ConsoleInput parameter'
)]
public function withInput(ConsoleInput $input): ExitCode
{
$args = $input->getArguments();
echo "Arguments: " . json_encode($args) . "\n";
return ExitCode::SUCCESS;
}
/**
* Command with both ConsoleInput and ConsoleOutput
*/
#[ConsoleCommand(
name: 'demo:with-both',
description: 'Demo command with both ConsoleInput and ConsoleOutput'
)]
public function withBoth(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine("Input arguments: " . json_encode($input->getArguments()));
return ExitCode::SUCCESS;
}
/**
* Command mixing framework and user parameters (Output first)
*/
#[ConsoleCommand(
name: 'demo:mixed-output-first',
description: 'Demo command with mixed parameters (output first)'
)]
public function mixedOutputFirst(
ConsoleOutputInterface $output,
string $name,
int $count = 3
): ExitCode {
$output->writeLine("Name: {$name}");
$output->writeLine("Count: {$count}");
return ExitCode::SUCCESS;
}
/**
* Command mixing framework and user parameters (Output in middle)
*/
#[ConsoleCommand(
name: 'demo:mixed-output-middle',
description: 'Demo command with mixed parameters (output in middle)'
)]
public function mixedOutputMiddle(
string $name,
ConsoleOutputInterface $output,
int $count = 3
): ExitCode {
$output->writeLine("Name: {$name}");
$output->writeLine("Count: {$count}");
return ExitCode::SUCCESS;
}
/**
* Command mixing framework and user parameters (Output last)
*/
#[ConsoleCommand(
name: 'demo:mixed-output-last',
description: 'Demo command with mixed parameters (output last)'
)]
public function mixedOutputLast(
string $name,
int $count,
ConsoleOutputInterface $output
): ExitCode {
$output->writeLine("Name: {$name}");
$output->writeLine("Count: {$count}");
return ExitCode::SUCCESS;
}
/**
* Command with all types mixed
*/
#[ConsoleCommand(
name: 'demo:all-mixed',
description: 'Demo command with all parameter types mixed'
)]
public function allMixed(
ConsoleInput $input,
string $operation,
ConsoleOutputInterface $output,
int $iterations = 1,
bool $verbose = false
): ExitCode {
$output->writeLine("Operation: {$operation}");
$output->writeLine("Iterations: {$iterations}");
$output->writeLine("Verbose: " . ($verbose ? 'true' : 'false'));
if ($verbose) {
$output->writeLine("Raw arguments: " . json_encode($input->getArguments()));
}
return ExitCode::SUCCESS;
}
}

View File

@@ -16,6 +16,7 @@ final readonly class MethodSignatureAnalyzer
{
/**
* Generate ArgumentDefinitions from method parameters
* Excludes framework-provided parameters (ConsoleInput, ConsoleOutput)
*
* @return ArgumentDefinition[]
*/
@@ -24,6 +25,11 @@ final readonly class MethodSignatureAnalyzer
$definitions = [];
foreach ($method->getParameters() as $param) {
// Skip framework-provided parameters
if ($this->isFrameworkProvidedParameter($param)) {
continue;
}
$definition = $this->createDefinitionFromParameter($param);
$definitions[$param->getName()] = $definition;
}
@@ -31,6 +37,33 @@ final readonly class MethodSignatureAnalyzer
return $definitions;
}
/**
* Check if parameter is framework-provided (ConsoleInput, ConsoleOutput)
*/
private function isFrameworkProvidedParameter(ReflectionParameter $param): bool
{
$type = $param->getType();
if (! ($type instanceof ReflectionNamedType)) {
return false;
}
$typeName = $type->getName();
$frameworkTypes = [
'App\\Framework\\Console\\ConsoleInput',
'App\\Framework\\Console\\ConsoleInputInterface',
'App\\Framework\\Console\\ConsoleOutputInterface',
'App\\Framework\\Console\\ConsoleOutput',
'ConsoleInput',
'ConsoleInputInterface',
'ConsoleOutputInterface',
'ConsoleOutput',
];
return in_array($typeName, $frameworkTypes, true);
}
/**
* Create ArgumentDefinition from ReflectionParameter
*/
@@ -112,6 +145,11 @@ final readonly class MethodSignatureAnalyzer
}
if ($type instanceof ReflectionUnionType) {
// Special case: ?bool (nullable bool) = flag
if ($this->isNullableBool($type)) {
return ArgumentType::BOOLEAN;
}
// For union types, try to find the most specific type
foreach ($type->getTypes() as $unionType) {
if ($unionType instanceof ReflectionNamedType && ! $unionType->isBuiltin()) {
@@ -131,6 +169,36 @@ final readonly class MethodSignatureAnalyzer
return ArgumentType::STRING;
}
/**
* Check if type is nullable bool (?bool)
*/
private function isNullableBool(ReflectionUnionType $type): bool
{
$types = $type->getTypes();
if (count($types) !== 2) {
return false;
}
$hasNull = false;
$hasBool = false;
foreach ($types as $unionType) {
if (!($unionType instanceof ReflectionNamedType)) {
return false;
}
$name = strtolower($unionType->getName());
if ($name === 'null') {
$hasNull = true;
} elseif ($name === 'bool' || $name === 'boolean') {
$hasBool = true;
}
}
return $hasNull && $hasBool;
}
/**
* Map ReflectionNamedType to ArgumentType
*/
@@ -289,7 +357,12 @@ final readonly class MethodSignatureAnalyzer
}
if ($type instanceof ReflectionUnionType) {
// Union types are complex, be conservative
// ?bool (nullable bool) is allowed for flags
if ($this->isNullableBool($type)) {
return true;
}
// Other union types are complex, be conservative
return false;
}
@@ -302,7 +375,7 @@ final readonly class MethodSignatureAnalyzer
return in_array(strtolower($typeName), $allowedTypes, true) ||
$this->isFrameworkValueObject($typeName) ||
$this->isConsoleType($typeName);
$this->isFrameworkProvidedParameter($param);
}
return false;
@@ -318,21 +391,4 @@ final readonly class MethodSignatureAnalyzer
str_contains($className, 'Url') ||
str_contains($className, 'ValueObject');
}
/**
* Check if type is a console-related type
*/
private function isConsoleType(string $className): bool
{
$consoleTypes = [
'App\\Framework\\Console\\ConsoleInput',
'App\\Framework\\Console\\ConsoleOutputInterface',
'App\\Framework\\Console\\ConsoleOutput',
'ConsoleInput',
'ConsoleOutputInterface',
'ConsoleOutput',
];
return in_array($className, $consoleTypes, true);
}
}

View File

@@ -5,7 +5,8 @@ declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Core\ValueObjects\Email;
use App\Framework\Core\ValueObjects\Url;
use App\Framework\Http\Url\Url;
use App\Framework\Http\Url\UrlFactory;
/**
* Container for parsed and validated console arguments
@@ -114,7 +115,7 @@ final readonly class ParsedArguments
*/
public function getUrl(string $name): Url
{
return new Url($this->getString($name));
return UrlFactory::parse($this->getString($name));
}
/**

View File

@@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Security\Commands;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Input\ConsoleInput;
use App\Framework\Console\Layout\ResponsiveOutput;
use App\Framework\Console\Output\ConsoleOutput;
final readonly class DependencyAuditCommand
{
#[ConsoleCommand(
name: 'security:audit',
description: 'Audit dependencies for known security vulnerabilities'
)]
public function audit(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$format = $input->getOption('format') ?? 'table';
$noDevDeps = $input->hasOption('no-dev');
$failOnVulnerabilities = $input->hasOption('fail-on-vulnerabilities');
$output->writeLine("<yellow>Running Security Audit...</yellow>\n");
// Build composer audit command
$command = 'composer audit --format=' . escapeshellarg($format);
if ($noDevDeps) {
$command .= ' --no-dev';
}
// Execute composer audit
$startTime = microtime(true);
exec($command . ' 2>&1', $outputLines, $returnCode);
$duration = microtime(true) - $startTime;
$outputText = implode("\n", $outputLines);
// Parse JSON output if format is JSON
if ($format === 'json') {
$auditData = json_decode($outputText, true);
if (json_last_error() === JSON_ERROR_NONE) {
$this->displayJsonResults($auditData, $output);
return $this->determineExitCode(
$auditData,
$failOnVulnerabilities
);
}
}
// Display raw output for non-JSON formats
$output->writeLine($outputText);
$output->writeLine("\n<gray>Audit completed in " . number_format($duration, 2) . "s</gray>");
// Return appropriate exit code
if ($returnCode === 0) {
$output->writeLine("\n<green>✓ No security vulnerabilities found!</green>");
return ExitCode::SUCCESS;
}
$output->writeLine("\n<red>✗ Security vulnerabilities detected!</red>");
return $failOnVulnerabilities ? ExitCode::FAILURE : ExitCode::SUCCESS;
}
#[ConsoleCommand(
name: 'security:audit-summary',
description: 'Show summary of dependency security audit'
)]
public function auditSummary(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$output->writeLine("<yellow>Security Audit Summary</yellow>\n");
// Execute composer audit with JSON format
exec('composer audit --format=json 2>&1', $outputLines, $returnCode);
$auditData = json_decode(implode("\n", $outputLines), true);
if (json_last_error() !== JSON_ERROR_NONE) {
$output->writeLine('<red>Failed to parse audit results</red>');
return ExitCode::FAILURE;
}
$responsiveOutput = ResponsiveOutput::create($output);
// Count vulnerabilities by severity
$severityCounts = [
'critical' => 0,
'high' => 0,
'medium' => 0,
'low' => 0,
];
$totalVulnerabilities = 0;
$affectedPackages = [];
foreach ($auditData['advisories'] ?? [] as $packageName => $advisories) {
$affectedPackages[] = $packageName;
foreach ($advisories as $advisory) {
$severity = strtolower($advisory['severity'] ?? 'unknown');
if (isset($severityCounts[$severity])) {
$severityCounts[$severity]++;
}
$totalVulnerabilities++;
}
}
// Display summary
$summary = [
'Total Vulnerabilities' => $totalVulnerabilities,
'Critical' => "<red>{$severityCounts['critical']}</red>",
'High' => "<yellow>{$severityCounts['high']}</yellow>",
'Medium' => "<cyan>{$severityCounts['medium']}</cyan>",
'Low' => "<gray>{$severityCounts['low']}</gray>",
'Affected Packages' => count($affectedPackages),
'Abandoned Packages' => count($auditData['abandoned'] ?? []),
];
$responsiveOutput->writeKeyValue($summary);
if ($totalVulnerabilities > 0) {
$output->writeLine("\n<red>⚠ Security vulnerabilities detected!</red>");
$output->writeLine("Run <cyan>php console.php security:audit</cyan> for details");
} else {
$output->writeLine("\n<green>✓ No security vulnerabilities found!</green>");
}
if (!empty($auditData['abandoned'])) {
$output->writeLine("\n<yellow>Abandoned Packages:</yellow>");
foreach ($auditData['abandoned'] as $package => $replacement) {
$replacementText = $replacement ? " (use {$replacement} instead)" : '';
$output->writeLine("{$package}{$replacementText}");
}
}
return $totalVulnerabilities > 0 ? ExitCode::FAILURE : ExitCode::SUCCESS;
}
#[ConsoleCommand(
name: 'security:audit-details',
description: 'Show detailed information about a specific package vulnerability'
)]
public function auditDetails(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$packageName = $input->getArgument('package');
if (!$packageName) {
$output->writeLine('<red>Package name is required</red>');
$output->writeLine('Usage: php console.php security:audit-details <package>');
return ExitCode::INVALID_ARGUMENTS;
}
// Execute composer audit with JSON format
exec('composer audit --format=json 2>&1', $outputLines);
$auditData = json_decode(implode("\n", $outputLines), true);
if (json_last_error() !== JSON_ERROR_NONE) {
$output->writeLine('<red>Failed to parse audit results</red>');
return ExitCode::FAILURE;
}
// Find package advisories
$advisories = $auditData['advisories'][$packageName] ?? null;
if (!$advisories) {
$output->writeLine("<yellow>No vulnerabilities found for package: {$packageName}</yellow>");
return ExitCode::SUCCESS;
}
$output->writeLine("<yellow>Vulnerabilities for {$packageName}</yellow>\n");
foreach ($advisories as $advisory) {
$this->displayAdvisoryDetails($advisory, $output);
$output->writeLine(str_repeat('─', 80));
}
return ExitCode::SUCCESS;
}
private function displayJsonResults(array $auditData, ConsoleOutput $output): void
{
$advisories = $auditData['advisories'] ?? [];
$abandoned = $auditData['abandoned'] ?? [];
if (empty($advisories) && empty($abandoned)) {
$output->writeLine('<green>✓ No security issues found!</green>');
return;
}
if (!empty($advisories)) {
$output->writeLine("<red>Security Vulnerabilities:</red>\n");
foreach ($advisories as $packageName => $packageAdvisories) {
$output->writeLine("<yellow>{$packageName}</yellow>");
foreach ($packageAdvisories as $advisory) {
$severity = $advisory['severity'] ?? 'unknown';
$severityColor = $this->getSeverityColor($severity);
$cve = $advisory['cve'] ?? 'N/A';
$title = $advisory['title'] ?? 'No title';
$output->writeLine(" • [{$severityColor}{$severity}</color>] {$cve}: {$title}");
if (!empty($advisory['link'])) {
$output->writeLine(" Link: <cyan>{$advisory['link']}</cyan>");
}
}
$output->writeLine('');
}
}
if (!empty($abandoned)) {
$output->writeLine("<yellow>Abandoned Packages:</yellow>\n");
foreach ($abandoned as $package => $replacement) {
$replacementText = $replacement ? " (use {$replacement})" : '';
$output->writeLine("{$package}{$replacementText}");
}
}
}
private function displayAdvisoryDetails(array $advisory, ConsoleOutput $output): void
{
$responsiveOutput = ResponsiveOutput::create($output);
$details = [
'Title' => $advisory['title'] ?? 'Unknown',
'CVE' => $advisory['cve'] ?? 'N/A',
'Severity' => $this->formatSeverity($advisory['severity'] ?? 'unknown'),
'Affected Versions' => $advisory['affectedVersions'] ?? 'N/A',
'Source' => $advisory['sources'][0]['remoteId'] ?? 'N/A',
];
$responsiveOutput->writeKeyValue($details);
if (!empty($advisory['link'])) {
$output->writeLine("\n<cyan>More Info:</cyan> {$advisory['link']}");
}
$output->writeLine('');
}
private function getSeverityColor(string $severity): string
{
return match (strtolower($severity)) {
'critical' => '<red>',
'high' => '<yellow>',
'medium' => '<cyan>',
'low' => '<gray>',
default => '<white>',
};
}
private function formatSeverity(string $severity): string
{
$color = $this->getSeverityColor($severity);
return "{$color}" . strtoupper($severity) . '</color>';
}
private function determineExitCode(array $auditData, bool $failOnVulnerabilities): ExitCode
{
$hasVulnerabilities = !empty($auditData['advisories']);
if ($hasVulnerabilities && $failOnVulnerabilities) {
return ExitCode::FAILURE;
}
return ExitCode::SUCCESS;
}
}

View File

@@ -88,7 +88,7 @@ final readonly class Application implements ApplicationInterface
'route' => $request->path,
'method' => $request->method->value,
'user_agent' => $request->server->getUserAgent()?->toString(),
'client_ip' => (string) $request->server->getClientIp()
'client_ip' => (string) $request->server->getClientIp(),
]
));
@@ -119,7 +119,7 @@ final readonly class Application implements ApplicationInterface
context: [
'status_code' => $response->status->value,
'content_length' => strlen($response->body),
'memory_peak' => memory_get_peak_usage(true)
'memory_peak' => memory_get_peak_usage(true),
]
));
@@ -138,7 +138,7 @@ final readonly class Application implements ApplicationInterface
response: $response,
context: [
'content_type' => $response->headers->getFirst('Content-Type', 'text/html'),
'cache_control' => $response->headers->getFirst('Cache-Control', 'no-cache')
'cache_control' => $response->headers->getFirst('Cache-Control', 'no-cache'),
]
));
@@ -162,7 +162,7 @@ final readonly class Application implements ApplicationInterface
totalProcessingTime: $totalTime,
context: [
'bytes_sent' => strlen($response->body),
'final_status' => $response->status->value
'final_status' => $response->status->value,
]
));
}

View File

@@ -9,7 +9,7 @@ use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Discovery\FileVisitor;
use App\Framework\Discovery\ReflectionAwareVisitor;
use App\Framework\Filesystem\FilePath;
use App\Framework\Filesystem\ValueObjects\FilePath;
use App\Framework\Reflection\WrappedReflectionClass;
/**

View File

@@ -4,12 +4,12 @@ declare(strict_types=1);
namespace App\Framework\Core\Events;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\MethodName;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Http\Request;
use App\Framework\Http\Response;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\MethodName;
final readonly class AfterControllerExecution
{
@@ -26,4 +26,4 @@ final readonly class AfterControllerExecution
) {
$this->timestamp = $timestamp ?? Timestamp::now();
}
}
}

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Framework\Core\Events;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Http\Request;
use App\Framework\Http\Response;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Core\ValueObjects\Duration;
final readonly class AfterEmitResponse
{

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Framework\Core\Events;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Http\Request;
use App\Framework\Http\Response;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Core\ValueObjects\Duration;
final readonly class AfterHandleRequest
{

View File

@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Framework\Core\Events;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Http\Request;
use App\Framework\Http\Response;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\ClassName;
final readonly class AfterMiddlewareExecution
{
@@ -24,4 +24,4 @@ final readonly class AfterMiddlewareExecution
) {
$this->timestamp = $timestamp ?? Timestamp::now();
}
}
}

View File

@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace App\Framework\Core\Events;
use App\Framework\Http\Request;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Http\Request;
final readonly class AfterRouteMatching
{
@@ -22,4 +22,4 @@ final readonly class AfterRouteMatching
) {
$this->timestamp = $timestamp ?? Timestamp::now();
}
}
}

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Framework\Core\Events;
use App\Framework\Http\Request;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\MethodName;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Http\Request;
final readonly class BeforeControllerExecution
{
@@ -23,4 +23,4 @@ final readonly class BeforeControllerExecution
) {
$this->timestamp = $timestamp ?? Timestamp::now();
}
}
}

Some files were not shown because too many files have changed in this diff Show More