stateValidator = new DefaultStateValidator(); } /** * Handle component action * * @param LiveComponentContract $component The component instance * @param string $method The action method to call * @param ActionParameters $params Parameters for the action * @return ComponentUpdate Update with new state and events (HTML will be filled by controller) * @throws \RuntimeException if CSRF validation fails * @throws \BadMethodCallException if method doesn't exist * @throws UnauthorizedActionException if authorization check fails * @throws StateValidationException if state validation fails * @throws RateLimitExceededException if rate limit is exceeded */ public function handle( LiveComponentContract $component, string $method, ActionParameters $params ): ComponentUpdate { // 1. CSRF Protection - validate token before executing action // Skip CSRF for 'poll' action as it's framework-initiated, not user-initiated if ($method !== 'poll') { $this->validateCsrf($component->id, $params); } // 2. Validate action is allowed (not reserved and has #[Action] attribute) $actionAttribute = $this->validateAction($component, $method); // 3. Idempotency Protection - check if this request has already been processed if ($params->hasIdempotencyKey() && $actionAttribute?->idempotencyTTL !== null) { return $this->handleWithIdempotency( $component, $method, $params, $actionAttribute ); } // 4. Rate Limiting - check if client has exceeded rate limits $this->validateRateLimit($component, $method, $params, $actionAttribute); // 5. Authorization Check - verify user has required permissions $this->validateAuthorization($component, $method); // 6. Execute action without idempotency return $this->executeActionAndBuildUpdate($component, $method, $params); } /** * Handle action with idempotency protection * * Uses IdempotencyService to cache results and prevent duplicate execution. * If the idempotency key has been seen before, returns cached result. * * @param LiveComponentContract $component Component instance * @param string $method Action method name * @param ActionParameters $params Action parameters with idempotency key * @param Action $actionAttribute Action attribute with TTL configuration * @return ComponentUpdate Cached or fresh component update */ private function handleWithIdempotency( LiveComponentContract $component, string $method, ActionParameters $params, Action $actionAttribute ): ComponentUpdate { $idempotencyKey = $params->getIdempotencyKey(); // Use IdempotencyService to execute with caching return $this->idempotency->execute( key: $idempotencyKey, operation: function () use ($component, $method, $params, $actionAttribute): ComponentUpdate { // Rate Limiting - check if client has exceeded rate limits $this->validateRateLimit($component, $method, $params, $actionAttribute); // Authorization Check - verify user has required permissions $this->validateAuthorization($component, $method); // Execute action and build update return $this->executeActionAndBuildUpdate($component, $method, $params); }, ttl: \App\Framework\Core\ValueObjects\Duration::fromSeconds($actionAttribute->idempotencyTTL) ); } /** * Execute action and build component update * * Core action execution logic extracted for reuse in both * idempotent and non-idempotent flows. * * @param LiveComponentContract $component Component instance * @param string $method Action method name * @param ActionParameters $params Action parameters * @return ComponentUpdate Component update with new state and events */ private function executeActionAndBuildUpdate( LiveComponentContract $component, string $method, ActionParameters $params ): ComponentUpdate { return $this->performanceTracker->measure( "livecomponent.{$component->id->name}.{$method}", PerformanceCategory::CUSTOM, function () use ($component, $method, $params): ComponentUpdate { // 1. Derive or get cached schema $schema = $this->performanceTracker->measure( "livecomponent.schema.derive", PerformanceCategory::CACHE, fn() => $this->getOrDeriveSchema($component), ['component' => $component->id->name] ); // 2. Clear any previous events $this->eventDispatcher->clear(); // 3. Call action method with params and event dispatcher // Components can optionally accept ComponentEventDispatcher as last parameter $newData = $this->performanceTracker->measure( "livecomponent.action.execute", PerformanceCategory::CUSTOM, fn() => $this->executeAction($component, $method, $params), ['action' => $method, 'component' => $component->id->name] ); // 4. Extract component name from ID $componentId = $component->id; $componentName = $componentId->name; // 5. Get dispatched events $events = $this->eventDispatcher->getEvents(); // 6. Action methods now return State VOs directly (e.g., CounterState, SearchState) // If action didn't return anything, get current state $stateObject = $newData ?? $component->state; // 7. Validate state against derived schema $this->performanceTracker->measure( "livecomponent.state.validate", PerformanceCategory::CUSTOM, fn() => $this->stateValidator->validateState($stateObject, $schema), ['component' => $component->id->name] ); // 8. Call onUpdate() lifecycle hook if component implements LifecycleAware $this->performanceTracker->measure( "livecomponent.lifecycle.onUpdate", PerformanceCategory::CUSTOM, fn() => $this->callUpdateHook($component, $stateObject), ['component' => $component->id->name] ); // 9. Convert State VO to array for serialization $stateArray = $stateObject->toArray(); // 10. Build ComponentUpdate // The LiveComponentController will render HTML using ComponentRegistry $componentUpdate = new ComponentUpdate( html: '', // Will be populated by controller events: $events, state: new LiveComponentState( id: $componentId->toString(), component: $componentName, data: $stateArray ) ); // 11. Dispatch domain event for SSE broadcasting // This enables real-time updates via Server-Sent Events $this->frameworkEventDispatcher->dispatch( new ComponentUpdatedEvent( componentId: $componentId, state: $stateArray, html: null, // HTML will be rendered by controller events: $events ) ); return $componentUpdate; }, ['component' => $component->id->name, 'action' => $method] ); } /** * Execute action method with advanced parameter binding * * Uses ParameterBinder for: * - Builtin type casting * - DTO instantiation via constructor promotion * - Framework service injection * - Multiple naming conventions */ private function executeAction( LiveComponentContract $component, string $method, ActionParameters $params ): mixed { // Try reflection first for real methods with parameter analysis try { $reflection = new \ReflectionMethod($component, $method); } catch (\ReflectionException $e) { // Magic method via __call() - call directly with params array // The component's __call() implementation handles parameter mapping return $component->$method(...$params->toArray()); } // Use ParameterBinder for advanced parameter binding $args = $this->parameterBinder->bindParameters($reflection, $params); // Call method with bound arguments return $component->$method(...$args); } /** * Handle file upload action * * Similar to handle() but specifically for file uploads. * Calls handleUpload() on components that implement SupportsFileUpload. * * @param SupportsFileUpload $component The component that supports uploads * @param UploadedFile $file The uploaded file * @param ActionParameters $params Additional upload parameters * @return ComponentUpdate Update with new state and events * @throws \RuntimeException if CSRF validation fails * @throws UnauthorizedActionException if authorization check fails * @throws StateValidationException if state validation fails */ public function handleUpload( SupportsFileUpload $component, UploadedFile $file, ActionParameters $params ): ComponentUpdate { // 1. CSRF Protection - validate token before executing upload $this->validateCsrf($component->id, $params); // 2. Authorization Check - verify user has required permissions for upload $this->validateAuthorization($component, 'handleUpload'); // 3. Derive or get cached schema $schema = $this->getOrDeriveSchema($component); // 4. Clear any previous events $this->eventDispatcher->clear(); // Call handleUpload with file, params, and event dispatcher $newData = $component->handleUpload($file, $params, $this->eventDispatcher); // Extract component name from ID $componentId = $component->id; $componentName = $componentId->name; // Get dispatched events $events = $this->eventDispatcher->getEvents(); // handleUpload() now returns State VO directly $stateObject = $newData ?? $component->state; // 5. Validate state against derived schema $this->stateValidator->validateState($stateObject, $schema); // 6. Call onUpdate() lifecycle hook if component implements LifecycleAware $this->callUpdateHook($component, $stateObject); // 7. Convert State VO to array $stateArray = $stateObject->toArray(); // 8. Build ComponentUpdate $componentUpdate = new ComponentUpdate( html: '', // Will be populated by controller events: $events, state: new LiveComponentState( id: $componentId->toString(), component: $componentName, data: $stateArray ) ); // 9. Dispatch domain event for SSE broadcasting $this->frameworkEventDispatcher->dispatch( new ComponentUpdatedEvent( componentId: $componentId, state: $stateArray, html: null, // HTML will be rendered by controller events: $events ) ); return $componentUpdate; } /** * Validate CSRF token for component action * * Uses component ID as form ID for CSRF validation. * This ensures each component instance has its own CSRF protection. * * @param ComponentId $componentId Component identifier * @param ActionParameters $params Action parameters containing CSRF token * @throws \RuntimeException if CSRF validation fails * @throws \InvalidArgumentException if CSRF token is missing or invalid */ private function validateCsrf(ComponentId $componentId, ActionParameters $params): void { // Check if CSRF token is present if (! $params->hasCsrfToken()) { throw new \InvalidArgumentException( 'CSRF token is required for LiveComponent actions' ); } $csrfToken = $params->getCsrfToken(); // Use component ID as form ID for CSRF validation // This provides per-component-instance CSRF protection $formId = 'livecomponent:' . $componentId->toString(); // Validate token using session's CSRF protection if (! $this->session->csrf->validateToken($formId, $csrfToken)) { throw new \RuntimeException( 'CSRF token validation failed for component: ' . $componentId->toString() ); } } /** * Validate that an action is allowed to be called * * Checks: * 1. Method is not reserved (framework methods, lifecycle hooks, magic methods) * 2. Method exists on component * 3. Method has #[Action] attribute (allow-list) * 4. Method is public and non-static * * @return Action|null The Action attribute instance (for rate limit configuration) * @throws \BadMethodCallException if action is invalid */ private function validateAction(LiveComponentContract $component, string $method): ?Action { // 1. Check if method is reserved if (ReservedActionName::isReserved($method)) { $reserved = ReservedActionName::tryFrom($method); $reason = $reserved?->getReasonMessage() ?? 'Reserved method'; throw new \BadMethodCallException( "Cannot call reserved method '{$method}' as action on " . get_class($component) . ". " . "Reason: {$reason}. " . "Available actions: " . implode(', ', $this->getAvailableActions($component)) ); } // 2. Check if method exists if (! method_exists($component, $method)) { // Find similar action names for better error messages $availableActions = $this->getAvailableActions($component); $suggestions = $this->findSimilarActions($method, $availableActions); $errorMessage = "Action '{$method}' not found on " . get_class($component) . "."; if (! empty($suggestions)) { $errorMessage .= " Did you mean: " . implode(', ', $suggestions) . "?"; } if (! empty($availableActions)) { $errorMessage .= " Available actions: " . implode(', ', $availableActions); } else { $errorMessage .= " Component has no available actions (no methods with #[Action] attribute)."; } throw new \BadMethodCallException($errorMessage); } // 3. Check if method has #[Action] attribute $reflection = new \ReflectionMethod($component, $method); $actionAttributes = $reflection->getAttributes(Action::class); if (empty($actionAttributes)) { $availableActions = $this->getAvailableActions($component); throw new \BadMethodCallException( "Method '{$method}' on " . get_class($component) . " is not marked as an action. " . "Add #[Action] attribute to make it callable from client. " . "Available actions: " . implode(', ', $availableActions) ); } // 4. Check if method is public and non-static if (! $reflection->isPublic()) { throw new \BadMethodCallException( "Action '{$method}' on " . get_class($component) . " must be public" ); } if ($reflection->isStatic()) { throw new \BadMethodCallException( "Action '{$method}' on " . get_class($component) . " cannot be static" ); } // 5. Validate return type is an object (State VO) $returnType = $reflection->getReturnType(); if ($returnType instanceof \ReflectionNamedType) { $typeName = $returnType->getName(); // Must return an object type (not primitive, not array) if (in_array($typeName, ['void', 'null', 'int', 'float', 'string', 'bool', 'array'], true)) { throw new \BadMethodCallException( "Action '{$method}' on " . get_class($component) . " must return a State object (e.g., CounterState, SearchState), not {$typeName}" ); } } // Return the Action attribute instance for rate limit configuration return $actionAttributes[0]->newInstance(); } /** * Get list of available actions (methods with #[Action] attribute) * * @return array */ private function getAvailableActions(LiveComponentContract $component): array { $reflection = new \ReflectionClass($component); $actions = []; foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { // Skip static methods if ($method->isStatic()) { continue; } // Skip reserved methods if (ReservedActionName::isReserved($method->getName())) { continue; } // Check for #[Action] attribute $actionAttributes = $method->getAttributes(Action::class); if (! empty($actionAttributes)) { $actions[] = $method->getName(); } } return $actions; } /** * Find similar action names using Levenshtein distance * * @param string $searchAction Action user tried to call * @param array $availableActions Available actions on component * @return array Similar action names (distance <= 3) */ private function findSimilarActions(string $searchAction, array $availableActions): array { $suggestions = []; foreach ($availableActions as $actionName) { $distance = levenshtein( strtolower($searchAction), strtolower($actionName) ); // Consider it a suggestion if distance is small if ($distance <= 3) { $suggestions[] = $actionName; } } return $suggestions; } /** * Validate authorization for component action * * Checks if user has required permissions via #[RequiresPermission] attribute. * If no attribute is present, authorization is not required. * * @param LiveComponentContract $component Component instance * @param string $method Action method name * @throws UnauthorizedActionException if authorization check fails */ private function validateAuthorization(LiveComponentContract $component, string $method): void { // Get RequiresPermission attribute from method $permissionAttribute = $this->getRequiresPermissionAttribute($component, $method); // No permission requirement → allow access if ($permissionAttribute === null) { return; } // Check authorization $isAuthorized = $this->authorizationChecker->isAuthorized( $component, $method, $permissionAttribute ); if (! $isAuthorized) { $componentName = $component->id->name; $userPermissions = $this->authorizationChecker->getUserPermissions(); // Check if it's an authentication issue (user not logged in) if (! $this->authorizationChecker->isAuthenticated()) { throw UnauthorizedActionException::forUnauthenticatedUser( $componentName, $method ); } // It's an authorization issue (user lacks permission) throw UnauthorizedActionException::forMissingPermission( $componentName, $method, $permissionAttribute, $userPermissions ); } } /** * Validate rate limiting for component action * * Checks if client has exceeded rate limits for this action. * Uses #[Action] attribute configuration or falls back to defaults. * * @param LiveComponentContract $component Component instance * @param string $method Action method name * @param ActionParameters $params Action parameters (contains client identifier) * @param Action|null $actionAttribute Action attribute for rate limit configuration * @throws RateLimitExceededException if rate limit is exceeded */ private function validateRateLimit( LiveComponentContract $component, string $method, ActionParameters $params, ?Action $actionAttribute ): void { // Skip if no client identifier if (! $params->hasClientIdentifier()) { return; } $clientIdentifier = $params->getClientIdentifier(); // Check rate limit $result = $this->rateLimiter->checkActionLimit( $component, $method, $clientIdentifier, $actionAttribute ); // Throw exception if exceeded if ($result->isExceeded()) { $componentName = $component->id->name; throw RateLimitExceededException::forAction( componentName: $componentName, action: $method, limit: $result->getLimit(), current: $result->getCurrent(), retryAfter: $result->getRetryAfter() ?? 60 ); } } /** * Get RequiresPermission attribute from action method * * @return RequiresPermission|null Permission attribute if present */ private function getRequiresPermissionAttribute( LiveComponentContract $component, string $method ): ?RequiresPermission { try { $reflection = new \ReflectionMethod($component, $method); $attributes = $reflection->getAttributes(RequiresPermission::class); if (empty($attributes)) { return null; } return $attributes[0]->newInstance(); } catch (\ReflectionException $e) { // Method doesn't exist or is not accessible return null; } } /** * Get or derive schema for component * * Uses SchemaCache for performance - derives schema once per component class. * Schema is derived from first getState() call and cached. * * @param LiveComponentContract $component Component to get schema for * @return DerivedSchema Cached or newly derived schema */ private function getOrDeriveSchema(LiveComponentContract $component): DerivedSchema { $componentClass = get_class($component); // Check cache $cachedSchema = $this->schemaCache->get($componentClass); if ($cachedSchema !== null) { return $cachedSchema; } // Derive schema from current component state $currentState = $component->state; $schema = $this->stateValidator->deriveSchemaFromState($currentState); // Cache for future use $this->schemaCache->set($componentClass, $schema); return $schema; } /** * Call onUpdate() lifecycle hook if component implements LifecycleAware */ private function callUpdateHook( LiveComponentContract $component, object $newState ): void { if (! $component instanceof \App\Framework\LiveComponents\Contracts\LifecycleAware) { return; } try { $component->onUpdate(); } catch (\Throwable $e) { // Log lifecycle hook errors but don't fail the action // Components should handle their own errors in hooks error_log("Lifecycle hook onUpdate() failed for " . get_class($component) . ": " . $e->getMessage()); } } /** * Call onMount() lifecycle hook if component implements LifecycleAware * * This should be called by ComponentRegistry after first component creation. */ public function callMountHook(LiveComponentContract $component): void { if (! $component instanceof \App\Framework\LiveComponents\Contracts\LifecycleAware) { return; } $this->performanceTracker->measure( "livecomponent.lifecycle.onMount", PerformanceCategory::CUSTOM, function () use ($component): void { try { $component->onMount(); } catch (\Throwable $e) { // Log lifecycle hook errors but don't fail the creation error_log("Lifecycle hook onMount() failed for " . get_class($component) . ": " . $e->getMessage()); } }, ['component' => $component->id->name] ); } }