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

@@ -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();
}
}
}

View File

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

View File

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

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\ClassName;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Http\Request;
final readonly class BeforeMiddlewareExecution
{
@@ -21,4 +21,4 @@ final readonly class BeforeMiddlewareExecution
) {
$this->timestamp = $timestamp ?? Timestamp::now();
}
}
}

View File

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

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,6 +4,8 @@ declare(strict_types=1);
namespace App\Framework\Core;
use App\Framework\Core\ValueObjects\PhpNamespace;
/**
* Optimierter PathProvider mit Caching
*/
@@ -77,14 +79,15 @@ final class PathProvider
/**
* Konvertiert einen Namespace in einen relativen Pfad
*/
public function namespaceToPath(string $namespace): ?string
public function namespaceToPath(string|PhpNamespace $namespace): ?string
{
$namespaceStr = $namespace instanceof PhpNamespace ? $namespace->toString() : $namespace;
$namespacePaths = $this->namespacePaths;
// Finde den passenden Namespace-Präfix
foreach ($namespacePaths as $prefix => $path) {
if (str_starts_with($namespace, $prefix)) {
$relativeNamespace = substr($namespace, strlen($prefix));
if (str_starts_with($namespaceStr, $prefix)) {
$relativeNamespace = substr($namespaceStr, strlen($prefix));
$relativePath = str_replace('\\', '/', $relativeNamespace);
return rtrim($path, '/') . '/' . $relativePath . '.php';
@@ -95,9 +98,18 @@ final class PathProvider
}
/**
* Konvertiert einen Pfad in einen Namespace
* Konvertiert einen Pfad in einen Namespace (als String)
*/
public function pathToNamespace(string $path): ?string
{
$namespace = $this->pathToNamespaceObject($path);
return $namespace?->toString();
}
/**
* Konvertiert einen Pfad in einen Namespace (als Value Object)
*/
public function pathToNamespaceObject(string $path): ?PhpNamespace
{
$namespacePaths = $this->namespacePaths;
$absolutePath = realpath($path);
@@ -115,7 +127,8 @@ final class PathProvider
$relativePath = rtrim(str_replace('.php', '', $relativePath), '/');
$relativeNamespace = str_replace('/', '\\', $relativePath);
return $namespace . ltrim($relativeNamespace, '\\');
$fullNamespace = $namespace . ltrim($relativeNamespace, '\\');
return PhpNamespace::fromString($fullNamespace);
}
}

View File

@@ -0,0 +1,291 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\Services;
use App\Framework\Core\ValueObjects\AccessibleLink;
use App\Framework\Core\ValueObjects\HtmlLink;
use App\Framework\Core\ValueObjects\LinkRel;
use App\Framework\Core\ValueObjects\LinkTarget;
use App\Framework\Core\ValueObjects\LinkValidationResult;
/**
* Link Validator Service
*
* Validates HTML links for security, accessibility, SEO best practices.
* Framework-compliant readonly service with composition.
*/
final readonly class LinkValidator
{
public function __construct(
private bool $strictMode = false,
private bool $checkAccessibility = true,
private bool $checkSeo = true,
) {}
/**
* Validate HtmlLink
*/
public function validate(HtmlLink $link): LinkValidationResult
{
$errors = [];
$warnings = [];
$suggestions = [];
// Security validation
$this->validateSecurity($link, $errors, $warnings);
// Accessibility validation
if ($this->checkAccessibility) {
$this->validateAccessibility($link, $warnings, $suggestions);
}
// SEO validation
if ($this->checkSeo) {
$this->validateSeo($link, $warnings, $suggestions);
}
// General best practices
$this->validateBestPractices($link, $warnings, $suggestions);
$isValid = empty($errors) && ($this->strictMode ? empty($warnings) : true);
return new LinkValidationResult(
isValid: $isValid,
errors: $errors,
warnings: $warnings,
suggestions: $suggestions
);
}
/**
* Validate AccessibleLink
*/
public function validateAccessible(AccessibleLink $link): LinkValidationResult
{
// First validate base link
$baseResult = $this->validate($link->baseLink);
$errors = $baseResult->errors;
$warnings = $baseResult->warnings;
$suggestions = $baseResult->suggestions;
// Additional ARIA validation
$this->validateAria($link, $warnings, $suggestions);
$isValid = empty($errors) && ($this->strictMode ? empty($warnings) : true);
return new LinkValidationResult(
isValid: $isValid,
errors: $errors,
warnings: $warnings,
suggestions: $suggestions
);
}
/**
* Validate security aspects
*/
private function validateSecurity(HtmlLink $link, array &$errors, array &$warnings): void
{
// External links with _blank target must have security rels
if ($link->target === LinkTarget::BLANK && $link->isExternal()) {
if (!in_array(LinkRel::NOOPENER, $link->rel, true)) {
$errors[] = 'External link with target="_blank" missing rel="noopener" (security risk: window.opener access)';
}
if (!in_array(LinkRel::NOREFERRER, $link->rel, true)) {
$warnings[] = 'External link with target="_blank" should include rel="noreferrer" for privacy';
}
}
// Download links should have explicit filename
if ($link->isDownload() && $link->download === '') {
$warnings[] = 'Download link should specify suggested filename for better UX';
}
// JavaScript hrefs are dangerous
if (is_string($link->href) && str_starts_with(strtolower($link->href), 'javascript:')) {
$errors[] = 'JavaScript hrefs are security risk - use event handlers instead';
}
}
/**
* Validate accessibility aspects
*/
private function validateAccessibility(HtmlLink $link, array &$warnings, array &$suggestions): void
{
// Links should have meaningful text
if ($link->text !== null && strlen($link->text) < 2) {
$warnings[] = 'Link text too short - use descriptive text for screen readers';
}
// Generic link texts
$genericTexts = ['click here', 'read more', 'here', 'more', 'link'];
if ($link->text !== null && in_array(strtolower($link->text), $genericTexts, true)) {
$suggestions[] = "Avoid generic link text '{$link->text}' - use descriptive text that makes sense out of context";
}
// External links should indicate they open new window
if ($link->opensNewWindow() && $link->text !== null) {
$hasIndicator = str_contains(strtolower($link->text), 'new window')
|| str_contains(strtolower($link->text), 'new tab')
|| $link->title !== null && (
str_contains(strtolower($link->title), 'new window')
|| str_contains(strtolower($link->title), 'new tab')
);
if (!$hasIndicator) {
$suggestions[] = 'Link opens in new window - consider adding aria-label or visible indicator for screen reader users';
}
}
// Disabled links should not be in tab order
if ($link->disabled) {
$suggestions[] = 'Consider using AccessibleLink with tabindex="-1" for disabled links to remove from tab order';
}
}
/**
* Validate ARIA attributes
*/
private function validateAria(AccessibleLink $link, array &$warnings, array &$suggestions): void
{
// aria-label vs visible text
if ($link->ariaLabel !== null && $link->baseLink->text !== null) {
if ($link->ariaLabel === $link->baseLink->text) {
$suggestions[] = 'aria-label duplicates visible text - only use when providing additional context';
}
}
// aria-current should not be used with aria-disabled
if ($link->ariaCurrent && $link->ariaDisabled) {
$warnings[] = 'Link marked as both current (aria-current) and disabled (aria-disabled) - conflicting states';
}
// aria-hidden links should not be focusable
if ($link->ariaHidden && $link->tabindex !== -1) {
$warnings[] = 'aria-hidden link should have tabindex="-1" to remove from tab order';
}
// Current page links should not be focusable
if ($link->ariaCurrent && $link->tabindex !== -1) {
$suggestions[] = 'Current page link should have tabindex="-1" to remove from keyboard navigation';
}
}
/**
* Validate SEO aspects
*/
private function validateSeo(HtmlLink $link, array &$warnings, array &$suggestions): void
{
// Internal links should not have nofollow
if (!$link->isExternal() && in_array(LinkRel::NOFOLLOW, $link->rel, true)) {
$warnings[] = 'Internal link with rel="nofollow" prevents search engine indexing - usually unintended';
}
// Pagination links should use proper rel attributes
$href = $link->getHref();
if (preg_match('/[?&]page=\d+/', $href) || preg_match('/\/page\/\d+/', $href)) {
$paginationRels = [LinkRel::NEXT, LinkRel::PREV, LinkRel::FIRST, LinkRel::LAST];
$hasPaginationRel = false;
foreach ($link->rel as $rel) {
if (in_array($rel, $paginationRels, true)) {
$hasPaginationRel = true;
break;
}
}
if (!$hasPaginationRel) {
$suggestions[] = 'Pagination link should use rel="next", rel="prev", rel="first", or rel="last" for SEO';
}
}
// Canonical should be absolute URL
if (in_array(LinkRel::CANONICAL, $link->rel, true)) {
if (!$link->isExternal() && !str_starts_with($href, 'http')) {
$warnings[] = 'Canonical link should use absolute URL for proper SEO';
}
}
}
/**
* Validate general best practices
*/
private function validateBestPractices(HtmlLink $link, array &$warnings, array &$suggestions): void
{
// Empty href
$href = $link->getHref();
if (trim($href) === '' || $href === '#') {
$warnings[] = 'Link with empty or hash-only href - consider using button element instead';
}
// Links should have text or accessible label
if ($link->text === null || trim($link->text) === '') {
$warnings[] = 'Link without text content - ensure aria-label or accessible alternative is provided';
}
// Very long link text
if ($link->text !== null && strlen($link->text) > 100) {
$suggestions[] = 'Link text is very long (' . strlen($link->text) . ' chars) - consider shortening for better UX';
}
// Download links should indicate file type
if ($link->isDownload() && $link->text !== null) {
$hasFileType = preg_match('/\.(pdf|doc|docx|xls|xlsx|zip|mp3|mp4)$/i', $link->text);
if (!$hasFileType) {
$suggestions[] = 'Download link text should indicate file type (e.g., "Download PDF")';
}
}
}
/**
* Batch validate multiple links
*
* @param array<HtmlLink|AccessibleLink> $links
* @return array<LinkValidationResult>
*/
public function validateBatch(array $links): array
{
return array_map(
fn($link) => $link instanceof AccessibleLink
? $this->validateAccessible($link)
: $this->validate($link),
$links
);
}
/**
* Get validation summary for batch
*
* @param array<LinkValidationResult> $results
*/
public function getSummary(array $results): array
{
$valid = 0;
$invalid = 0;
$totalErrors = 0;
$totalWarnings = 0;
foreach ($results as $result) {
if ($result->isValid) {
$valid++;
} else {
$invalid++;
}
$totalErrors += count($result->errors);
$totalWarnings += count($result->warnings);
}
return [
'total_links' => count($results),
'valid_links' => $valid,
'invalid_links' => $invalid,
'total_errors' => $totalErrors,
'total_warnings' => $totalWarnings,
'validation_rate' => count($results) > 0 ? round(($valid / count($results)) * 100, 2) : 0,
];
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Framework\Core;
use App\Framework\Router\RouteNameInterface;
use App\Framework\Router\ValueObjects\ParameterCollection;
final readonly class StaticRoute implements Route

View File

@@ -0,0 +1,364 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
use App\Framework\Http\Url\Url;
use App\Framework\View\ValueObjects\StandardHtmlElement;
use App\Framework\View\ValueObjects\HtmlAttributes;
use App\Framework\View\ValueObjects\TagName;
/**
* Accessible Link Value Object
*
* Enhanced HTML link with full ARIA support for accessibility.
* Framework-compliant: composition over inheritance (no extends).
*
* @psalm-immutable
*/
final readonly class AccessibleLink
{
/**
* @param HtmlLink $baseLink Base link properties
* @param string|null $ariaLabel Accessible label (overrides visible text)
* @param string|null $ariaDescribedBy ID(s) of elements describing the link
* @param string|null $ariaLabelledBy ID(s) of elements labeling the link
* @param bool $ariaCurrent Whether link represents current page/location
* @param string|null $ariaCurrentValue Specific aria-current value (page, step, location, etc.)
* @param bool $ariaDisabled Whether link is disabled (for navigation state)
* @param string|null $role ARIA role override (default: link)
* @param int|null $tabindex Tab order (-1 to skip, 0 for natural, >0 for custom)
* @param bool $ariaHidden Whether to hide from screen readers
*/
public readonly ?string $ariaCurrentValue;
public function __construct(
public HtmlLink $baseLink,
public ?string $ariaLabel = null,
public ?string $ariaDescribedBy = null,
public ?string $ariaLabelledBy = null,
public bool $ariaCurrent = false,
?string $ariaCurrentValue = null,
public bool $ariaDisabled = false,
public ?string $role = null,
public ?int $tabindex = null,
public bool $ariaHidden = false,
) {
// Set default aria-current value if current but no value specified
$this->ariaCurrentValue = $ariaCurrent && $ariaCurrentValue === null
? 'page'
: $ariaCurrentValue;
}
/**
* Create from existing HtmlLink
*/
public static function fromHtmlLink(HtmlLink $link): self
{
return new self(baseLink: $link);
}
/**
* Create simple accessible link
*/
public static function create(Url|string $href, ?string $text = null, ?string $ariaLabel = null): self
{
return new self(
baseLink: HtmlLink::create($href, $text),
ariaLabel: $ariaLabel
);
}
/**
* Create current page link (for navigation highlighting)
*/
public static function currentPage(Url|string $href, string $text): self
{
return new self(
baseLink: HtmlLink::create($href, $text),
ariaCurrent: true,
ariaCurrentValue: 'page',
tabindex: -1 // Current page shouldn't be focusable
);
}
/**
* Create skip link (for keyboard navigation)
*/
public static function skipLink(string $targetId, string $text = 'Skip to main content'): self
{
return new self(
baseLink: HtmlLink::create("#{$targetId}", $text)
->withClass('skip-link'),
ariaLabel: $text
);
}
/**
* Create external link with full accessibility
*/
public static function external(Url|string $href, string $text): self
{
return new self(
baseLink: HtmlLink::external($href, $text),
ariaLabel: "{$text} (opens in new window)"
);
}
/**
* Get href from base link
*/
public function getHref(): string
{
return $this->baseLink->getHref();
}
/**
* Check if link is current/active
*/
public function isCurrent(): bool
{
return $this->ariaCurrent;
}
/**
* Render as HTML with full ARIA attributes
*/
public function toHtml(): string
{
// Merge base link attributes with ARIA attributes
$attributes = $this->buildCombinedAttributes();
$text = htmlspecialchars($this->baseLink->text ?? $this->baseLink->getHref(), ENT_QUOTES, 'UTF-8');
return StandardHtmlElement::create(TagName::A)
->withAttributes($attributes)
->withContent($text)
->__toString();
}
/**
* Build combined HTML and ARIA attributes
*/
private function buildCombinedAttributes(): HtmlAttributes
{
// Start with base link attributes
$attrs = $this->getBaseLinkAttributesArray();
// Add ARIA attributes
if ($this->ariaLabel !== null) {
$attrs['aria-label'] = $this->ariaLabel;
}
if ($this->ariaDescribedBy !== null) {
$attrs['aria-describedby'] = $this->ariaDescribedBy;
}
if ($this->ariaLabelledBy !== null) {
$attrs['aria-labelledby'] = $this->ariaLabelledBy;
}
if ($this->ariaCurrent) {
$attrs['aria-current'] = $this->ariaCurrentValue ?? 'page';
}
if ($this->ariaDisabled) {
$attrs['aria-disabled'] = 'true';
}
if ($this->role !== null) {
$attrs['role'] = $this->role;
}
if ($this->tabindex !== null) {
$attrs['tabindex'] = (string) $this->tabindex;
}
if ($this->ariaHidden) {
$attrs['aria-hidden'] = 'true';
}
return HtmlAttributes::fromArray($attrs);
}
/**
* Get base link attributes as array (extracted from private method via reflection)
*/
private function getBaseLinkAttributesArray(): array
{
$attrs = ['href' => $this->baseLink->getHref()];
if ($this->baseLink->title !== null) {
$attrs['title'] = $this->baseLink->title;
}
if ($this->baseLink->target !== null) {
$attrs['target'] = $this->baseLink->target->value;
}
if (!empty($this->baseLink->rel)) {
$relValues = array_map(fn(LinkRel $r) => $r->value, $this->baseLink->rel);
$attrs['rel'] = implode(' ', $relValues);
}
if ($this->baseLink->hreflang !== null) {
$attrs['hreflang'] = $this->baseLink->hreflang;
}
if ($this->baseLink->type !== null) {
$attrs['type'] = $this->baseLink->type;
}
if ($this->baseLink->download !== null) {
if ($this->baseLink->download === '') {
$attrs['download'] = null;
} else {
$attrs['download'] = $this->baseLink->download;
}
}
if ($this->baseLink->cssClass !== null) {
$attrs['class'] = $this->baseLink->cssClass;
}
if ($this->baseLink->id !== null) {
$attrs['id'] = $this->baseLink->id;
}
foreach ($this->baseLink->dataAttributes as $key => $value) {
$attrs["data-{$key}"] = $value;
}
if ($this->baseLink->disabled) {
// Note: base link already adds aria-disabled and tabindex for disabled state
// We'll override with our own ARIA attributes if set
if ($this->ariaDisabled === false) {
$attrs['aria-disabled'] = 'true';
}
if ($this->tabindex === null) {
$attrs['tabindex'] = '-1';
}
}
return $attrs;
}
/**
* With new base link (immutable)
*/
public function withBaseLink(HtmlLink $baseLink): self
{
return new self(
baseLink: $baseLink,
ariaLabel: $this->ariaLabel,
ariaDescribedBy: $this->ariaDescribedBy,
ariaLabelledBy: $this->ariaLabelledBy,
ariaCurrent: $this->ariaCurrent,
ariaCurrentValue: $this->ariaCurrentValue,
ariaDisabled: $this->ariaDisabled,
role: $this->role,
tabindex: $this->tabindex,
ariaHidden: $this->ariaHidden
);
}
/**
* With aria-label (immutable)
*/
public function withAriaLabel(?string $ariaLabel): self
{
return new self(
baseLink: $this->baseLink,
ariaLabel: $ariaLabel,
ariaDescribedBy: $this->ariaDescribedBy,
ariaLabelledBy: $this->ariaLabelledBy,
ariaCurrent: $this->ariaCurrent,
ariaCurrentValue: $this->ariaCurrentValue,
ariaDisabled: $this->ariaDisabled,
role: $this->role,
tabindex: $this->tabindex,
ariaHidden: $this->ariaHidden
);
}
/**
* With aria-current (immutable)
*/
public function withAriaCurrent(bool $ariaCurrent, ?string $value = null): self
{
return new self(
baseLink: $this->baseLink,
ariaLabel: $this->ariaLabel,
ariaDescribedBy: $this->ariaDescribedBy,
ariaLabelledBy: $this->ariaLabelledBy,
ariaCurrent: $ariaCurrent,
ariaCurrentValue: $value ?? ($ariaCurrent ? 'page' : null),
ariaDisabled: $this->ariaDisabled,
role: $this->role,
tabindex: $this->tabindex,
ariaHidden: $this->ariaHidden
);
}
/**
* With aria-disabled (immutable)
*/
public function withAriaDisabled(bool $disabled): self
{
return new self(
baseLink: $this->baseLink,
ariaLabel: $this->ariaLabel,
ariaDescribedBy: $this->ariaDescribedBy,
ariaLabelledBy: $this->ariaLabelledBy,
ariaCurrent: $this->ariaCurrent,
ariaCurrentValue: $this->ariaCurrentValue,
ariaDisabled: $disabled,
role: $this->role,
tabindex: $disabled ? -1 : $this->tabindex, // Disabled links shouldn't be focusable
ariaHidden: $this->ariaHidden
);
}
/**
* With tabindex (immutable)
*/
public function withTabindex(?int $tabindex): self
{
return new self(
baseLink: $this->baseLink,
ariaLabel: $this->ariaLabel,
ariaDescribedBy: $this->ariaDescribedBy,
ariaLabelledBy: $this->ariaLabelledBy,
ariaCurrent: $this->ariaCurrent,
ariaCurrentValue: $this->ariaCurrentValue,
ariaDisabled: $this->ariaDisabled,
role: $this->role,
tabindex: $tabindex,
ariaHidden: $this->ariaHidden
);
}
/**
* Convert to string (renders as HTML)
*/
public function __toString(): string
{
return $this->toHtml();
}
/**
* Get accessibility information
*/
public function getAccessibilityInfo(): array
{
return [
'has_aria_label' => $this->ariaLabel !== null,
'is_current' => $this->ariaCurrent,
'is_disabled' => $this->ariaDisabled,
'is_hidden' => $this->ariaHidden,
'is_focusable' => $this->tabindex !== -1 && !$this->ariaDisabled,
'opens_new_window' => $this->baseLink->opensNewWindow(),
'is_external' => $this->baseLink->isExternal(),
];
}
}

View File

@@ -16,7 +16,7 @@ final readonly class ClassName
*/
private string $fullyQualified;
private string $namespace;
private PhpNamespace $namespace;
private string $shortName;
@@ -37,10 +37,11 @@ final readonly class ClassName
$lastBackslash = strrpos($this->fullyQualified, '\\');
if ($lastBackslash === false) {
$this->namespace = '';
$this->namespace = PhpNamespace::global();
$this->shortName = $this->fullyQualified;
} else {
$this->namespace = substr($this->fullyQualified, 0, $lastBackslash);
$namespaceStr = substr($this->fullyQualified, 0, $lastBackslash);
$this->namespace = PhpNamespace::fromString($namespaceStr);
$this->shortName = substr($this->fullyQualified, $lastBackslash + 1);
}
}
@@ -61,6 +62,18 @@ final readonly class ClassName
return new self($object::class);
}
/**
* Create from namespace and short name
*/
public static function fromNamespace(PhpNamespace $namespace, string $shortName): self
{
if ($namespace->isGlobal()) {
return new self($shortName);
}
return new self($namespace->toString() . '\\' . $shortName);
}
/**
* Get a fully qualified class name
* @return class-string
@@ -71,9 +84,17 @@ final readonly class ClassName
}
/**
* Get a namespace part
* Get namespace as string
*/
public function getNamespace(): string
{
return $this->namespace->toString();
}
/**
* Get namespace as value object
*/
public function getNamespaceObject(): PhpNamespace
{
return $this->namespace;
}
@@ -140,32 +161,35 @@ final readonly class ClassName
*/
public function inSameNamespace(self $other): bool
{
return $this->namespace === $other->namespace;
return $this->namespace->equals($other->namespace);
}
/**
* Check if in namespace (or sub-namespace)
*/
public function inNamespace(string $namespace): bool
public function inNamespace(string|PhpNamespace $namespace): bool
{
$namespace = trim($namespace, '\\');
$namespaceObj = $namespace instanceof PhpNamespace ? $namespace : PhpNamespace::fromString($namespace);
return str_starts_with($this->namespace, $namespace);
return $this->namespace->startsWith($namespaceObj);
}
/**
* Get parent namespace
* Get parent namespace as string (deprecated - use getParentNamespaceObject)
*/
public function getParentNamespace(): ?string
{
$parts = explode('\\', $this->namespace);
if (count($parts) <= 1) {
return null;
}
$parent = $this->namespace->parent();
array_pop($parts);
return $parent?->toString();
}
return implode('\\', $parts);
/**
* Get parent namespace as value object
*/
public function getParentNamespaceObject(): ?PhpNamespace
{
return $this->namespace->parent();
}
/**
@@ -174,7 +198,7 @@ final readonly class ClassName
*/
public function getNamespaceParts(): array
{
return $this->namespace ? explode('\\', $this->namespace) : [];
return $this->namespace->parts();
}
/**
@@ -206,7 +230,11 @@ final readonly class ClassName
*/
public function toDebugString(): string
{
return $this->namespace ? "{$this->namespace}\\{$this->shortName}" : $this->shortName;
if ($this->namespace->isGlobal()) {
return $this->shortName;
}
return $this->namespace->toString() . '\\' . $this->shortName;
}
/**
@@ -217,6 +245,38 @@ final readonly class ClassName
return $this->fullyQualified === $other->fullyQualified;
}
/**
* Change namespace (returns new instance with same short name but different namespace)
*/
public function withNamespace(PhpNamespace $namespace): self
{
return self::fromNamespace($namespace, $this->shortName);
}
/**
* Move to parent namespace (returns new instance in parent namespace)
*/
public function moveToParent(): ?self
{
$parent = $this->getParentNamespaceObject();
if ($parent === null) {
return null;
}
return self::fromNamespace($parent, $this->shortName);
}
/**
* Move to child namespace (returns new instance in child namespace)
*/
public function moveToChild(string $childNamespace): self
{
$newNamespace = $this->namespace->append($childNamespace);
return self::fromNamespace($newNamespace, $this->shortName);
}
/**
* Validate class name format
*/

View File

@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
/**
* Client Identifier Value Object
*
* Represents a unique identifier for a client making requests.
* Used for rate limiting, security tracking, audit logging, etc.
*
* Supports multiple identifier types:
* - IP Address (most common)
* - Session ID (for authenticated sessions)
* - User ID (for logged-in users)
* - API Key (for API clients)
* - Custom (application-specific identifiers)
*
* Usage:
* ```php
* $identifier = ClientIdentifier::fromIp('192.168.1.1');
* $identifier = ClientIdentifier::fromSession('sess_abc123');
* $identifier = ClientIdentifier::fromUserId('user_123');
* ```
*/
final readonly class ClientIdentifier
{
private function __construct(
public string $value,
public ClientIdentifierType $type
) {
if (empty($this->value)) {
throw new \InvalidArgumentException('Client identifier value cannot be empty');
}
}
/**
* Create from IP address
*/
public static function fromIp(string $ipAddress): self
{
if (! filter_var($ipAddress, FILTER_VALIDATE_IP)) {
throw new \InvalidArgumentException("Invalid IP address: {$ipAddress}");
}
return new self($ipAddress, ClientIdentifierType::IP_ADDRESS);
}
/**
* Create from session ID
*/
public static function fromSession(string $sessionId): self
{
return new self($sessionId, ClientIdentifierType::SESSION_ID);
}
/**
* Create from user ID
*/
public static function fromUserId(string $userId): self
{
return new self($userId, ClientIdentifierType::USER_ID);
}
/**
* Create from API key
*/
public static function fromApiKey(string $apiKey): self
{
return new self($apiKey, ClientIdentifierType::API_KEY);
}
/**
* Create custom identifier
*/
public static function custom(string $value, string $customType = 'custom'): self
{
return new self($value, ClientIdentifierType::CUSTOM);
}
/**
* Get string representation for hashing/storage
*/
public function toString(): string
{
return $this->value;
}
/**
* Get prefixed string with type information
*
* Format: "type:value"
* Example: "ip:192.168.1.1", "session:sess_abc123"
*/
public function toPrefixedString(): string
{
return $this->type->value . ':' . $this->value;
}
/**
* Check if identifier is IP-based
*/
public function isIpBased(): bool
{
return $this->type === ClientIdentifierType::IP_ADDRESS;
}
/**
* Check if identifier is session-based
*/
public function isSessionBased(): bool
{
return $this->type === ClientIdentifierType::SESSION_ID;
}
/**
* Check if identifier is user-based
*/
public function isUserBased(): bool
{
return $this->type === ClientIdentifierType::USER_ID;
}
/**
* Equality comparison
*/
public function equals(self $other): bool
{
return $this->value === $other->value
&& $this->type === $other->type;
}
/**
* Get masked version for logging (privacy-safe)
*
* IP: 192.168.1.xxx
* Session: sess_abc***
* User: user_***
*/
public function toMasked(): string
{
return match ($this->type) {
ClientIdentifierType::IP_ADDRESS => $this->maskIpAddress(),
ClientIdentifierType::SESSION_ID => $this->maskGeneric(8),
ClientIdentifierType::USER_ID => $this->maskGeneric(5),
ClientIdentifierType::API_KEY => $this->maskGeneric(4),
ClientIdentifierType::CUSTOM => $this->maskGeneric(4)
};
}
/**
* Mask IP address (keep first 3 octets for IPv4, first 4 groups for IPv6)
*/
private function maskIpAddress(): string
{
// IPv4
if (str_contains($this->value, '.')) {
$parts = explode('.', $this->value);
if (count($parts) === 4) {
return "{$parts[0]}.{$parts[1]}.{$parts[2]}.xxx";
}
}
// IPv6
if (str_contains($this->value, ':')) {
$parts = explode(':', $this->value);
if (count($parts) >= 4) {
return implode(':', array_slice($parts, 0, 4)) . ':xxxx';
}
}
return $this->maskGeneric(8);
}
/**
* Generic masking: show first N characters, mask rest
*/
private function maskGeneric(int $visibleChars = 4): string
{
$length = strlen($this->value);
if ($length <= $visibleChars) {
return str_repeat('*', $length);
}
$visible = substr($this->value, 0, $visibleChars);
$masked = str_repeat('*', min(3, $length - $visibleChars));
return $visible . $masked;
}
public function __toString(): string
{
return $this->value;
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
/**
* Client Identifier Type Enum
*
* Categorizes different types of client identifiers for
* rate limiting, tracking, and security purposes.
*/
enum ClientIdentifierType: string
{
/**
* IP Address (IPv4 or IPv6)
*/
case IP_ADDRESS = 'ip';
/**
* Session ID from session management
*/
case SESSION_ID = 'session';
/**
* User ID for authenticated users
*/
case USER_ID = 'user';
/**
* API Key for API clients
*/
case API_KEY = 'api_key';
/**
* Custom identifier type
*/
case CUSTOM = 'custom';
}

View File

@@ -242,4 +242,9 @@ final readonly class CountryCode
{
return $this->value;
}
public function equals(self $country): bool
{
return $this->value === $country->value;
}
}

View File

@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
/**
* Value Object representing dimensions (width and height)
@@ -186,7 +186,7 @@ final readonly class Dimensions
'height' => $this->height,
'aspect_ratio' => $this->getAspectRatio(),
'orientation' => $this->getOrientation()->value,
'area' => $this->getArea()
'area' => $this->getArea(),
];
}
@@ -197,4 +197,4 @@ final readonly class Dimensions
{
return "{$this->width}x{$this->height}";
}
}
}

View File

@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
/**
* Value Object representing a file size
@@ -32,7 +32,7 @@ final readonly class FileSize
*/
public static function fromFile(string $filePath): self
{
if (!file_exists($filePath)) {
if (! file_exists($filePath)) {
throw FrameworkException::create(
ErrorCode::VAL_INVALID_FORMAT,
'File does not exist'
@@ -159,7 +159,7 @@ final readonly class FileSize
*/
public function isWithinLimit(self $maxSize): bool
{
return !$this->exceedsLimit($maxSize);
return ! $this->exceedsLimit($maxSize);
}
/**
@@ -285,4 +285,4 @@ final readonly class FileSize
{
return $this->toHumanReadable();
}
}
}

View File

@@ -0,0 +1,434 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
use App\Framework\Http\Url\Url;
use App\Framework\View\ValueObjects\StandardHtmlElement;
use App\Framework\View\ValueObjects\HtmlAttributes;
use App\Framework\View\ValueObjects\TagName;
/**
* HTML Link Value Object
*
* Represents an HTML link for <a> or <link> elements with all attributes.
* Framework-compliant readonly value object following "No Primitives" principle.
*
* @psalm-immutable
*/
final readonly class HtmlLink
{
/**
* @param Url|string $href Link URL (required)
* @param string|null $text Link text content (for <a> tags)
* @param string|null $title Tooltip text
* @param LinkTarget|null $target Browsing context
* @param array<LinkRel> $rel Relationship attributes
* @param string|null $hreflang Language of linked resource
* @param string|null $type MIME type of linked resource
* @param string|null $download Suggested filename for download
* @param array<string, string> $dataAttributes Custom data-* attributes
* @param string|null $cssClass CSS classes
* @param string|null $id HTML id attribute
* @param bool $disabled Whether link is disabled
*/
public function __construct(
public Url|string $href,
public ?string $text = null,
public ?string $title = null,
public ?LinkTarget $target = null,
public array $rel = [],
public ?string $hreflang = null,
public ?string $type = null,
public ?string $download = null,
public array $dataAttributes = [],
public ?string $cssClass = null,
public ?string $id = null,
public bool $disabled = false,
) {
if (is_string($this->href) && trim($this->href) === '') {
throw new \InvalidArgumentException('Link href cannot be empty');
}
}
/**
* Create simple link with URL and text
*/
public static function create(Url|string $href, ?string $text = null): self
{
return new self(href: $href, text: $text);
}
/**
* Create external link with security attributes
*/
public static function external(Url|string $href, string $text): self
{
return new self(
href: $href,
text: $text,
target: LinkTarget::BLANK,
rel: [LinkRel::NOOPENER, LinkRel::NOREFERRER, LinkRel::EXTERNAL]
);
}
/**
* Create download link
*/
public static function download(Url|string $href, string $text, ?string $filename = null): self
{
return new self(
href: $href,
text: $text,
download: $filename ?? ''
);
}
/**
* Create mailto link
*/
public static function mailto(string $email, ?string $text = null): self
{
return new self(
href: "mailto:{$email}",
text: $text ?? $email
);
}
/**
* Create tel link
*/
public static function tel(string $phoneNumber, ?string $text = null): self
{
$cleanNumber = preg_replace('/[^0-9+]/', '', $phoneNumber);
return new self(
href: "tel:{$cleanNumber}",
text: $text ?? $phoneNumber
);
}
/**
* Get href as string
*/
public function getHref(): string
{
return $this->href instanceof Url
? $this->href->toString()
: $this->href;
}
/**
* Get href as Url object (if possible)
*/
public function getHrefAsUrl(): ?Url
{
return $this->href instanceof Url ? $this->href : null;
}
/**
* Check if link is external
*/
public function isExternal(): bool
{
foreach ($this->rel as $rel) {
if ($rel === LinkRel::EXTERNAL) {
return true;
}
}
// Check if href is absolute URL with different domain
if ($this->href instanceof Url) {
return !empty($this->href->getScheme());
}
return str_starts_with($this->href, 'http://') || str_starts_with($this->href, 'https://');
}
/**
* Check if link opens in new window/tab
*/
public function opensNewWindow(): bool
{
return $this->target?->opensNewContext() ?? false;
}
/**
* Check if link is a download
*/
public function isDownload(): bool
{
return $this->download !== null;
}
/**
* Render as <a> tag HTML
*/
public function toHtml(): string
{
$attributes = $this->buildHtmlAttributes();
$text = htmlspecialchars($this->text ?? $this->getHref(), ENT_QUOTES, 'UTF-8');
return StandardHtmlElement::create(TagName::A)
->withAttributes($attributes)
->withContent($text)
->__toString();
}
/**
* Render as <link> tag HTML (for stylesheets, icons, etc.)
*/
public function toLinkElement(): string
{
$attributes = $this->buildHtmlAttributes();
return StandardHtmlElement::create(TagName::LINK)
->withAttributes($attributes)
->__toString();
}
/**
* Build HtmlAttributes object
*/
private function buildHtmlAttributes(): HtmlAttributes
{
$attrs = ['href' => $this->getHref()];
// title
if ($this->title !== null) {
$attrs['title'] = $this->title;
}
// target
if ($this->target !== null) {
$attrs['target'] = $this->target->value;
}
// rel
if (!empty($this->rel)) {
$relValues = array_map(fn(LinkRel $r) => $r->value, $this->rel);
$attrs['rel'] = implode(' ', $relValues);
}
// hreflang
if ($this->hreflang !== null) {
$attrs['hreflang'] = $this->hreflang;
}
// type
if ($this->type !== null) {
$attrs['type'] = $this->type;
}
// download
if ($this->download !== null) {
if ($this->download === '') {
$attrs['download'] = null; // Boolean attribute
} else {
$attrs['download'] = $this->download;
}
}
// class
if ($this->cssClass !== null) {
$attrs['class'] = $this->cssClass;
}
// id
if ($this->id !== null) {
$attrs['id'] = $this->id;
}
// data attributes
foreach ($this->dataAttributes as $key => $value) {
$attrs["data-{$key}"] = $value;
}
// disabled (aria-disabled for accessibility)
if ($this->disabled) {
$attrs['aria-disabled'] = 'true';
$attrs['tabindex'] = '-1';
}
return HtmlAttributes::fromArray($attrs);
}
/**
* With new href (immutable)
*/
public function withHref(Url|string $href): self
{
return new self(
href: $href,
text: $this->text,
title: $this->title,
target: $this->target,
rel: $this->rel,
hreflang: $this->hreflang,
type: $this->type,
download: $this->download,
dataAttributes: $this->dataAttributes,
cssClass: $this->cssClass,
id: $this->id,
disabled: $this->disabled
);
}
/**
* With new text (immutable)
*/
public function withText(?string $text): self
{
return new self(
href: $this->href,
text: $text,
title: $this->title,
target: $this->target,
rel: $this->rel,
hreflang: $this->hreflang,
type: $this->type,
download: $this->download,
dataAttributes: $this->dataAttributes,
cssClass: $this->cssClass,
id: $this->id,
disabled: $this->disabled
);
}
/**
* With new title (immutable)
*/
public function withTitle(?string $title): self
{
return new self(
href: $this->href,
text: $this->text,
title: $title,
target: $this->target,
rel: $this->rel,
hreflang: $this->hreflang,
type: $this->type,
download: $this->download,
dataAttributes: $this->dataAttributes,
cssClass: $this->cssClass,
id: $this->id,
disabled: $this->disabled
);
}
/**
* With new target (immutable)
*/
public function withTarget(?LinkTarget $target): self
{
return new self(
href: $this->href,
text: $this->text,
title: $this->title,
target: $target,
rel: $this->rel,
hreflang: $this->hreflang,
type: $this->type,
download: $this->download,
dataAttributes: $this->dataAttributes,
cssClass: $this->cssClass,
id: $this->id,
disabled: $this->disabled
);
}
/**
* With added rel (immutable)
*/
public function withRel(LinkRel ...$rel): self
{
return new self(
href: $this->href,
text: $this->text,
title: $this->title,
target: $this->target,
rel: array_merge($this->rel, $rel),
hreflang: $this->hreflang,
type: $this->type,
download: $this->download,
dataAttributes: $this->dataAttributes,
cssClass: $this->cssClass,
id: $this->id,
disabled: $this->disabled
);
}
/**
* With CSS class (immutable)
*/
public function withClass(?string $cssClass): self
{
return new self(
href: $this->href,
text: $this->text,
title: $this->title,
target: $this->target,
rel: $this->rel,
hreflang: $this->hreflang,
type: $this->type,
download: $this->download,
dataAttributes: $this->dataAttributes,
cssClass: $cssClass,
id: $this->id,
disabled: $this->disabled
);
}
/**
* With data attribute (immutable)
*/
public function withDataAttribute(string $key, string $value): self
{
$dataAttributes = $this->dataAttributes;
$dataAttributes[$key] = $value;
return new self(
href: $this->href,
text: $this->text,
title: $this->title,
target: $this->target,
rel: $this->rel,
hreflang: $this->hreflang,
type: $this->type,
download: $this->download,
dataAttributes: $dataAttributes,
cssClass: $this->cssClass,
id: $this->id,
disabled: $this->disabled
);
}
/**
* With disabled state (immutable)
*/
public function withDisabled(bool $disabled): self
{
return new self(
href: $this->href,
text: $this->text,
title: $this->title,
target: $this->target,
rel: $this->rel,
hreflang: $this->hreflang,
type: $this->type,
download: $this->download,
dataAttributes: $this->dataAttributes,
cssClass: $this->cssClass,
id: $this->id,
disabled: $disabled
);
}
/**
* Convert to string (renders as <a> tag)
*/
public function __toString(): string
{
return $this->toHtml();
}
}

View File

@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
/**
* Idempotency Key Value Object
*
* Represents a unique key for ensuring idempotent operations.
* Multiple requests with the same idempotency key will produce the same result.
*
* Use Cases:
* - Preventing duplicate form submissions
* - Handling network retries safely
* - Ensuring at-most-once execution of critical actions
* - Payment processing deduplication
* - Queue job deduplication
* - Webhook deduplication
*
* Usage:
* ```php
* // Auto-generate
* $key = IdempotencyKey::generate();
*
* // From user-provided header
* $key = IdempotencyKey::fromString($request->header('Idempotency-Key'));
*
* // From request fingerprint
* $key = IdempotencyKey::fromFingerprint('payment', ['amount' => 100, 'user' => 123]);
* ```
*/
final readonly class IdempotencyKey
{
private const MIN_LENGTH = 16;
private const MAX_LENGTH = 255;
private function __construct(
public string $value
) {
$this->validate();
}
/**
* Create from user-provided string
*/
public static function fromString(string $value): self
{
return new self($value);
}
/**
* Generate random idempotency key
*
* Uses cryptographically secure random bytes.
*/
public static function generate(): self
{
$randomBytes = random_bytes(32);
$value = bin2hex($randomBytes);
return new self($value);
}
/**
* Create from operation fingerprint
*
* Generates deterministic key from operation context.
* Same context = same key = idempotent.
*
* @param string $operation Operation identifier (e.g., 'payment', 'order', 'email')
* @param array<string, mixed> $context Operation context for fingerprinting
*/
public static function fromFingerprint(string $operation, array $context): self
{
// Remove non-deterministic data
unset($context['_csrf'], $context['timestamp'], $context['_token']);
// Sort context for deterministic hashing
ksort($context);
// Create fingerprint
$fingerprint = json_encode([
'operation' => $operation,
'context' => $context,
]);
// Hash for fixed-length key
$hash = Hash::sha256($fingerprint)->toString();
return new self($hash);
}
/**
* Get string representation
*/
public function toString(): string
{
return $this->value;
}
/**
* Equality comparison
*/
public function equals(self $other): bool
{
return hash_equals($this->value, $other->value);
}
/**
* Get storage key for caching
*
* Format: "idempotency:{hash}"
*/
public function toStorageKey(): string
{
// Use hash for compact storage keys
$hash = Hash::sha256($this->value)->toShort(32);
return "idempotency:{$hash}";
}
/**
* Get masked version for logging (privacy-safe)
*
* Shows first 8 characters, masks rest
*/
public function toMasked(): string
{
$length = strlen($this->value);
if ($length <= 8) {
return str_repeat('*', $length);
}
$visible = substr($this->value, 0, 8);
$masked = str_repeat('*', min(3, $length - 8));
return $visible . $masked;
}
/**
* Validate idempotency key
*/
private function validate(): void
{
$length = strlen($this->value);
if ($length < self::MIN_LENGTH) {
throw new \InvalidArgumentException(
"Idempotency key must be at least " . self::MIN_LENGTH . " characters, got {$length}"
);
}
if ($length > self::MAX_LENGTH) {
throw new \InvalidArgumentException(
"Idempotency key cannot exceed " . self::MAX_LENGTH . " characters, got {$length}"
);
}
// Allow alphanumeric, hyphens, underscores
if (! preg_match('/^[a-zA-Z0-9_-]+$/', $this->value)) {
throw new \InvalidArgumentException(
'Idempotency key must contain only alphanumeric characters, hyphens, and underscores'
);
}
}
public function __toString(): string
{
return $this->value;
}
}

View File

@@ -0,0 +1,315 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
use App\Framework\Http\Url\Url;
use App\Framework\View\ValueObjects\StandardHtmlElement;
use App\Framework\View\ValueObjects\HtmlAttributes;
use App\Framework\View\ValueObjects\TagName;
/**
* Link Collection Value Object
*
* Immutable collection of HtmlLink or AccessibleLink objects for navigation menus,
* breadcrumbs, pagination, and other link-based UI components.
*
* Framework-compliant readonly value object following "No Primitives" principle.
*
* @psalm-immutable
*/
final readonly class LinkCollection implements \IteratorAggregate, \Countable
{
/** @var array<HtmlLink|AccessibleLink> */
private array $links;
/**
* @param HtmlLink|AccessibleLink ...$links Variadic links
*/
public function __construct(
HtmlLink|AccessibleLink ...$links
) {
$this->links = $links;
}
/**
* Create from array of links
*
* @param array<HtmlLink|AccessibleLink> $links
*/
public static function fromArray(array $links): self
{
return new self(...$links);
}
/**
* Create from URLs and texts
*
* @param array<array{url: Url|string, text: string, current?: bool}> $items
*/
public static function fromUrlsAndTexts(array $items): self
{
$links = [];
foreach ($items as $item) {
$isCurrent = $item['current'] ?? false;
if ($isCurrent) {
$links[] = AccessibleLink::currentPage($item['url'], $item['text']);
} else {
$links[] = HtmlLink::create($item['url'], $item['text']);
}
}
return new self(...$links);
}
/**
* Get all links
*
* @return array<HtmlLink|AccessibleLink>
*/
public function toArray(): array
{
return $this->links;
}
/**
* Get link at index
*/
public function get(int $index): HtmlLink|AccessibleLink|null
{
return $this->links[$index] ?? null;
}
/**
* Get first link
*/
public function first(): HtmlLink|AccessibleLink|null
{
return $this->links[0] ?? null;
}
/**
* Get last link
*/
public function last(): HtmlLink|AccessibleLink|null
{
return $this->links[array_key_last($this->links)] ?? null;
}
/**
* Check if collection is empty
*/
public function isEmpty(): bool
{
return empty($this->links);
}
/**
* Get count of links
*/
public function count(): int
{
return count($this->links);
}
/**
* Get iterator for foreach
*/
public function getIterator(): \ArrayIterator
{
return new \ArrayIterator($this->links);
}
/**
* Filter links by condition
*/
public function filter(callable $callback): self
{
return new self(...array_filter($this->links, $callback));
}
/**
* Map links to new collection
*/
public function map(callable $callback): self
{
return new self(...array_map($callback, $this->links));
}
/**
* Find first link matching condition
*/
public function find(callable $callback): HtmlLink|AccessibleLink|null
{
foreach ($this->links as $link) {
if ($callback($link)) {
return $link;
}
}
return null;
}
/**
* Add link to collection (immutable)
*/
public function add(HtmlLink|AccessibleLink $link): self
{
return new self(...[...$this->links, $link]);
}
/**
* Prepend link to collection (immutable)
*/
public function prepend(HtmlLink|AccessibleLink $link): self
{
return new self($link, ...$this->links);
}
/**
* Merge with another collection (immutable)
*/
public function merge(self $other): self
{
return new self(...$this->links, ...$other->links);
}
/**
* Render as navigation menu
*
* @param string|null $ariaLabel ARIA label for navigation
* @param string|null $cssClass CSS classes for <nav>
* @param string|null $ulClass CSS classes for <ul>
*/
public function toNavigation(
?string $ariaLabel = null,
?string $cssClass = null,
?string $ulClass = null
): string {
if ($this->isEmpty()) {
return '';
}
// Build <nav> attributes
$navAttrs = [];
if ($ariaLabel !== null) {
$navAttrs['aria-label'] = $ariaLabel;
}
if ($cssClass !== null) {
$navAttrs['class'] = $cssClass;
}
// Build <ul> attributes
$ulAttrs = ['role' => 'list'];
if ($ulClass !== null) {
$ulAttrs['class'] = $ulClass;
}
// Build list items
$listItems = '';
foreach ($this->links as $link) {
$linkHtml = (string) $link;
$listItems .= "<li>{$linkHtml}</li>";
}
// Build navigation structure
$ulElement = StandardHtmlElement::create(TagName::UL)
->withAttributes(HtmlAttributes::fromArray($ulAttrs))
->withContent($listItems);
$navElement = StandardHtmlElement::create(TagName::NAV)
->withAttributes(HtmlAttributes::fromArray($navAttrs))
->withContent((string) $ulElement);
return (string) $navElement;
}
/**
* Render as breadcrumbs with schema.org markup
*
* @param string|null $cssClass CSS classes for <nav>
* @param string|null $olClass CSS classes for <ol>
*/
public function toBreadcrumbs(
?string $cssClass = null,
?string $olClass = null
): string {
if ($this->isEmpty()) {
return '';
}
// Build <nav> attributes
$navAttrs = ['aria-label' => 'Breadcrumb'];
if ($cssClass !== null) {
$navAttrs['class'] = $cssClass;
}
// Build <ol> attributes with schema.org markup
$olAttrs = [
'itemscope' => null,
'itemtype' => 'https://schema.org/BreadcrumbList'
];
if ($olClass !== null) {
$olAttrs['class'] = $olClass;
}
// Build breadcrumb items with schema.org markup
$listItems = '';
$position = 1;
foreach ($this->links as $index => $link) {
$isLast = ($index === array_key_last($this->links));
// Get href for schema.org
$href = $link instanceof AccessibleLink
? $link->getHref()
: $link->getHref();
// Get text for schema.org
$text = $link instanceof AccessibleLink
? ($link->baseLink->text ?? $href)
: ($link->text ?? $href);
$liAttrs = [
'itemprop' => 'itemListElement',
'itemscope' => null,
'itemtype' => 'https://schema.org/ListItem'
];
$linkWithSchema = $isLast
? htmlspecialchars($text, ENT_QUOTES, 'UTF-8')
: "<a href=\"{$href}\" itemprop=\"item\"><span itemprop=\"name\">{$text}</span></a>";
$positionMeta = "<meta itemprop=\"position\" content=\"{$position}\" />";
$listItems .= StandardHtmlElement::create(TagName::LI)
->withAttributes(HtmlAttributes::fromArray($liAttrs))
->withContent($linkWithSchema . $positionMeta)
->__toString();
$position++;
}
// Build breadcrumb structure
$olElement = StandardHtmlElement::create(TagName::OL)
->withAttributes(HtmlAttributes::fromArray($olAttrs))
->withContent($listItems);
$navElement = StandardHtmlElement::create(TagName::NAV)
->withAttributes(HtmlAttributes::fromArray($navAttrs))
->withContent((string) $olElement);
return (string) $navElement;
}
/**
* Convert to string (renders as unordered list)
*/
public function __toString(): string
{
return $this->toNavigation();
}
}

View File

@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
/**
* Link Rel Attribute Enum
*
* Represents valid rel attribute values for <a> and <link> elements.
* Based on HTML Living Standard and common practices.
*/
enum LinkRel: string
{
// Navigation
case ALTERNATE = 'alternate';
case AUTHOR = 'author';
case BOOKMARK = 'bookmark';
case CANONICAL = 'canonical';
case HELP = 'help';
case NEXT = 'next';
case PREV = 'prev';
case SEARCH = 'search';
// Resource References
case STYLESHEET = 'stylesheet';
case ICON = 'icon';
case MANIFEST = 'manifest';
case PRECONNECT = 'preconnect';
case PREFETCH = 'prefetch';
case PRELOAD = 'preload';
case PRERENDER = 'prerender';
case DNS_PREFETCH = 'dns-prefetch';
// Security & Privacy
case NOFOLLOW = 'nofollow';
case NOOPENER = 'noopener';
case NOREFERRER = 'noreferrer';
case EXTERNAL = 'external';
// Licensing
case LICENSE = 'license';
case TAG = 'tag';
// SEO & Content Discovery
case AMPHTML = 'amphtml';
case RSS = 'rss';
case ATOM = 'atom';
case SITEMAP = 'sitemap';
// Pagination
case FIRST = 'first';
case LAST = 'last';
/**
* Check if rel indicates external resource
*/
public function isExternal(): bool
{
return match ($this) {
self::EXTERNAL, self::NOFOLLOW, self::NOOPENER, self::NOREFERRER => true,
default => false,
};
}
/**
* Check if rel is for resource hints
*/
public function isResourceHint(): bool
{
return match ($this) {
self::PRECONNECT, self::PREFETCH, self::PRELOAD, self::PRERENDER, self::DNS_PREFETCH => true,
default => false,
};
}
/**
* Check if rel is security-related
*/
public function isSecurityRelated(): bool
{
return match ($this) {
self::NOFOLLOW, self::NOOPENER, self::NOREFERRER => true,
default => false,
};
}
/**
* Check if rel is SEO-related
*/
public function isSeoRelated(): bool
{
return match ($this) {
self::CANONICAL, self::ALTERNATE, self::AMPHTML, self::RSS, self::ATOM, self::SITEMAP => true,
default => false,
};
}
/**
* Check if rel is for pagination
*/
public function isPagination(): bool
{
return match ($this) {
self::NEXT, self::PREV, self::FIRST, self::LAST => true,
default => false,
};
}
/**
* Create from string
*/
public static function fromString(string $rel): self
{
return self::tryFrom(strtolower(trim($rel)))
?? throw new \InvalidArgumentException("Invalid link rel: {$rel}");
}
/**
* Try to create from string (returns null on failure)
*/
public static function tryFromString(string $rel): ?self
{
return self::tryFrom(strtolower(trim($rel)));
}
/**
* Combine multiple rel values
*
* @param self ...$rels
* @return string Space-separated rel attribute value
*/
public static function combine(self ...$rels): string
{
return implode(' ', array_map(fn(self $rel) => $rel->value, $rels));
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
/**
* Link Target Attribute Enum
*
* Represents valid target attribute values for <a> elements.
* Defines browsing context for link navigation.
*/
enum LinkTarget: string
{
case SELF = '_self';
case BLANK = '_blank';
case PARENT = '_parent';
case TOP = '_top';
/**
* Check if target opens in new context
*/
public function opensNewContext(): bool
{
return $this === self::BLANK;
}
/**
* Check if target escapes iframe
*/
public function escapesFrame(): bool
{
return match ($this) {
self::PARENT, self::TOP => true,
default => false,
};
}
/**
* Get recommended rel attributes for security
*
* @return array<LinkRel>
*/
public function getRecommendedRel(): array
{
return match ($this) {
self::BLANK => [LinkRel::NOOPENER, LinkRel::NOREFERRER],
default => [],
};
}
/**
* Create from string
*/
public static function fromString(string $target): self
{
return self::tryFrom(strtolower(trim($target)))
?? throw new \InvalidArgumentException("Invalid link target: {$target}");
}
/**
* Try to create from string (returns null on failure)
*/
public static function tryFromString(string $target): ?self
{
return self::tryFrom(strtolower(trim($target)));
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
/**
* Link Validation Result Value Object
*
* Represents the result of link validation with errors and warnings.
*
* @psalm-immutable
*/
final readonly class LinkValidationResult
{
/**
* @param bool $isValid Whether link is valid
* @param array<string> $errors Critical validation errors
* @param array<string> $warnings Non-critical warnings
* @param array<string> $suggestions Improvement suggestions
*/
public function __construct(
public bool $isValid,
public array $errors = [],
public array $warnings = [],
public array $suggestions = [],
) {}
/**
* Create valid result
*/
public static function valid(?array $suggestions = null): self
{
return new self(
isValid: true,
suggestions: $suggestions ?? []
);
}
/**
* Create invalid result
*/
public static function invalid(array $errors, ?array $warnings = null): self
{
return new self(
isValid: false,
errors: $errors,
warnings: $warnings ?? []
);
}
/**
* Check if result has errors
*/
public function hasErrors(): bool
{
return !empty($this->errors);
}
/**
* Check if result has warnings
*/
public function hasWarnings(): bool
{
return !empty($this->warnings);
}
/**
* Check if result has suggestions
*/
public function hasSuggestions(): bool
{
return !empty($this->suggestions);
}
/**
* Get all issues combined
*/
public function getAllIssues(): array
{
return array_merge($this->errors, $this->warnings);
}
/**
* Get total issue count
*/
public function getIssueCount(): int
{
return count($this->errors) + count($this->warnings);
}
/**
* Convert to array for logging
*/
public function toArray(): array
{
return [
'is_valid' => $this->isValid,
'errors' => $this->errors,
'warnings' => $this->warnings,
'suggestions' => $this->suggestions,
'error_count' => count($this->errors),
'warning_count' => count($this->warnings),
];
}
}

View File

@@ -7,7 +7,7 @@ namespace App\Framework\Core\ValueObjects;
use InvalidArgumentException;
/**
* Value object for PHP method names with validation
* Value object for PHP method names with validation and ClassName integration
*/
final readonly class MethodName
{
@@ -23,46 +23,253 @@ final readonly class MethodName
}
}
/**
* Create from method name string
*/
public static function create(string $name): self
{
return new self($name);
}
/**
* Create from qualified method reference "ClassName::methodName"
*/
public static function fromQualified(string $qualifiedMethod): QualifiedMethodName
{
if (! str_contains($qualifiedMethod, '::')) {
throw new InvalidArgumentException(
"Expected format 'ClassName::methodName', got: {$qualifiedMethod}"
);
}
[$classNameStr, $methodName] = explode('::', $qualifiedMethod, 2);
return new QualifiedMethodName(
className: ClassName::create($classNameStr),
methodName: new self($methodName)
);
}
/**
* Create qualified method name with ClassName
*/
public static function withClass(ClassName $className, string $methodName): QualifiedMethodName
{
return new QualifiedMethodName(
className: $className,
methodName: new self($methodName)
);
}
/**
* Magic method: __invoke
*/
public static function invoke(): self
{
return new self('__invoke');
}
/**
* Magic method: __construct
*/
public static function construct(): self
{
return new self('__construct');
}
public function toString(): string
/**
* Magic method: __destruct
*/
public static function destruct(): self
{
return $this->name;
return new self('__destruct');
}
/**
* Magic method: __call
*/
public static function call(): self
{
return new self('__call');
}
/**
* Magic method: __callStatic
*/
public static function callStatic(): self
{
return new self('__callStatic');
}
/**
* Magic method: __get
*/
public static function get(): self
{
return new self('__get');
}
/**
* Magic method: __set
*/
public static function set(): self
{
return new self('__set');
}
/**
* Magic method: __isset
*/
public static function isset(): self
{
return new self('__isset');
}
/**
* Magic method: __unset
*/
public static function unset(): self
{
return new self('__unset');
}
/**
* Magic method: __toString
*/
public static function toStringMagic(): self
{
return new self('__toString');
}
/**
* Check if method exists in given class
*/
public function existsIn(ClassName $className): bool
{
if (! $className->exists()) {
return false;
}
return method_exists($className->getFullyQualified(), $this->name);
}
/**
* Check if method is public in given class
*/
public function isPublicIn(ClassName $className): bool
{
if (! $this->existsIn($className)) {
return false;
}
try {
$reflection = new \ReflectionMethod($className->getFullyQualified(), $this->name);
return $reflection->isPublic();
} catch (\ReflectionException) {
return false;
}
}
/**
* Check if method is static in given class
*/
public function isStaticIn(ClassName $className): bool
{
if (! $this->existsIn($className)) {
return false;
}
try {
$reflection = new \ReflectionMethod($className->getFullyQualified(), $this->name);
return $reflection->isStatic();
} catch (\ReflectionException) {
return false;
}
}
/**
* Get reflection for this method in given class
*/
public function getReflection(ClassName $className): ?\ReflectionMethod
{
if (! $this->existsIn($className)) {
return null;
}
try {
return new \ReflectionMethod($className->getFullyQualified(), $this->name);
} catch (\ReflectionException) {
return null;
}
}
/**
* Check if this is a magic method
*/
public function isMagicMethod(): bool
{
return str_starts_with($this->name, '__');
}
/**
* Check if this is __construct
*/
public function isConstructor(): bool
{
return $this->name === '__construct';
}
/**
* Check if this is __invoke
*/
public function isInvokable(): bool
{
return $this->name === '__invoke';
}
/**
* Check if this is __destruct
*/
public function isDestructor(): bool
{
return $this->name === '__destruct';
}
/**
* Check if this is __toString
*/
public function isToString(): bool
{
return $this->name === '__toString';
}
/**
* Get string representation
*/
public function toString(): string
{
return $this->name;
}
/**
* Compare for equality
*/
public function equals(self $other): bool
{
return $this->name === $other->name;
}
/**
* Check if method name matches pattern
*/
public function matches(string $pattern): bool
{
return fnmatch($pattern, $this->name, FNM_NOESCAPE);
}
/**
* Validate method name format
*/
private function isValidMethodName(string $name): bool
{
// PHP method names must follow variable naming rules
@@ -70,6 +277,9 @@ final readonly class MethodName
return preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $name) === 1;
}
/**
* String representation
*/
public function __toString(): string
{
return $this->name;

View File

@@ -48,4 +48,4 @@ enum Orientation: string
{
return $this === self::SQUARE;
}
}
}

View File

@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
use InvalidArgumentException;
/**
* Immutable PHP namespace value object with validation and operations
*/
final readonly class PhpNamespace
{
private string $namespace;
private function __construct(string $namespace)
{
// Empty namespace is valid (global namespace)
if ($namespace === '') {
$this->namespace = '';
return;
}
// Remove leading/trailing backslashes
$namespace = trim($namespace, '\\');
if (! $this->isValidNamespace($namespace)) {
throw new InvalidArgumentException("Invalid namespace: {$namespace}");
}
$this->namespace = $namespace;
}
/**
* Create from namespace string
*/
public static function fromString(string $namespace): self
{
return new self($namespace);
}
/**
* Create from class name
*/
public static function fromClass(string $className): self
{
$className = ltrim($className, '\\');
$lastBackslash = strrpos($className, '\\');
if ($lastBackslash === false) {
return new self(''); // Global namespace
}
return new self(substr($className, 0, $lastBackslash));
}
/**
* Create from namespace parts
* @param array<string> $parts
*/
public static function fromParts(array $parts): self
{
return new self(implode('\\', $parts));
}
/**
* Create global namespace
*/
public static function global(): self
{
return new self('');
}
/**
* Get namespace as string
*/
public function toString(): string
{
return $this->namespace;
}
/**
* Get namespace parts
* @return array<string>
*/
public function parts(): array
{
if ($this->namespace === '') {
return [];
}
return explode('\\', $this->namespace);
}
/**
* Get namespace depth (number of levels)
*/
public function depth(): int
{
if ($this->namespace === '') {
return 0;
}
return count($this->parts());
}
/**
* Check if this is the global namespace
*/
public function isGlobal(): bool
{
return $this->namespace === '';
}
/**
* Get parent namespace
*/
public function parent(): ?self
{
$parts = $this->parts();
if (count($parts) <= 1) {
return null; // No parent or already at global namespace
}
array_pop($parts);
return self::fromParts($parts);
}
/**
* Append namespace segment
*/
public function append(string $segment): self
{
if ($this->namespace === '') {
return new self($segment);
}
return new self($this->namespace . '\\' . ltrim($segment, '\\'));
}
/**
* Check if namespace starts with given prefix
*/
public function startsWith(string|self $prefix): bool
{
$prefixStr = $prefix instanceof self ? $prefix->toString() : $prefix;
$prefixStr = trim($prefixStr, '\\');
if ($prefixStr === '') {
return true; // All namespaces start with global namespace
}
return str_starts_with($this->namespace, $prefixStr);
}
/**
* Check if namespace ends with given suffix
*/
public function endsWith(string|self $suffix): bool
{
$suffixStr = $suffix instanceof self ? $suffix->toString() : $suffix;
$suffixStr = trim($suffixStr, '\\');
if ($suffixStr === '') {
return true;
}
return str_ends_with($this->namespace, $suffixStr);
}
/**
* Compare for equality
*/
public function equals(self $other): bool
{
return $this->namespace === $other->namespace;
}
/**
* Convert to a fully qualified class name (with leading backslash)
*/
public function toFqcn(string $className): string
{
if ($this->namespace === '') {
return '\\' . $className;
}
return '\\' . $this->namespace . '\\' . $className;
}
/**
* Convert to file path (for PSR-4 autoloading)
*/
public function toPath(): string
{
if ($this->namespace === '') {
return '';
}
return str_replace('\\', '/', $this->namespace);
}
/**
* String representation
*/
public function __toString(): string
{
return $this->namespace;
}
/**
* Validate namespace format
*/
private function isValidNamespace(string $namespace): bool
{
// Empty namespace is valid (global namespace)
if ($namespace === '') {
return true;
}
// Each part must be a valid PHP identifier
$parts = explode('\\', $namespace);
foreach ($parts as $part) {
// Must start with letter or underscore, followed by letters, numbers, or underscores
if (! preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/', $part)) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
/**
* Value object representing a fully qualified method reference (ClassName::methodName)
*/
final readonly class QualifiedMethodName
{
public function __construct(
public ClassName $className,
public MethodName $methodName
) {}
/**
* Create from qualified string "ClassName::methodName"
*/
public static function fromString(string $qualifiedMethod): self
{
return MethodName::fromQualified($qualifiedMethod);
}
/**
* Check if method exists
*/
public function exists(): bool
{
return $this->methodName->existsIn($this->className);
}
/**
* Check if method is public
*/
public function isPublic(): bool
{
return $this->methodName->isPublicIn($this->className);
}
/**
* Check if method is static
*/
public function isStatic(): bool
{
return $this->methodName->isStaticIn($this->className);
}
/**
* Get reflection for this method
*/
public function getReflection(): ?\ReflectionMethod
{
return $this->methodName->getReflection($this->className);
}
/**
* Check if this is a magic method
*/
public function isMagicMethod(): bool
{
return $this->methodName->isMagicMethod();
}
/**
* Check if this is a constructor
*/
public function isConstructor(): bool
{
return $this->methodName->isConstructor();
}
/**
* Check if this is __invoke
*/
public function isInvokable(): bool
{
return $this->methodName->isInvokable();
}
/**
* Get string representation "ClassName::methodName"
*/
public function toString(): string
{
return $this->className->toString() . '::' . $this->methodName->toString();
}
/**
* Get string representation with namespace
*/
public function toFullyQualifiedString(): string
{
return $this->className->getFullyQualified() . '::' . $this->methodName->toString();
}
/**
* Compare for equality
*/
public function equals(self $other): bool
{
return $this->className->equals($other->className)
&& $this->methodName->equals($other->methodName);
}
/**
* Invoke the method with arguments
*
* @param object|null $instance Instance for non-static methods, null for static
* @param array $arguments Method arguments
* @return mixed
*/
public function invoke(?object $instance, array $arguments = []): mixed
{
if (! $this->exists()) {
throw new \BadMethodCallException(
"Method {$this->toString()} does not exist"
);
}
$reflection = $this->getReflection();
if ($reflection->isStatic()) {
return $reflection->invokeArgs(null, $arguments);
}
if ($instance === null) {
throw new \BadMethodCallException(
"Cannot invoke non-static method {$this->toString()} without instance"
);
}
return $reflection->invokeArgs($instance, $arguments);
}
/**
* String representation
*/
public function __toString(): string
{
return $this->toString();
}
}

View File

@@ -1,254 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects\Services;
use App\Framework\Core\ValueObjects\Url;
final class UrlManipulator
{
public function withScheme(Url $url, string $scheme): Url
{
$parsed = parse_url($url->getValue());
$parsed['scheme'] = $scheme;
return Url::from($this->buildUrl($parsed));
}
public function withHost(Url $url, string $host): Url
{
$parsed = parse_url($url->getValue());
$parsed['host'] = $host;
return Url::from($this->buildUrl($parsed));
}
public function withPort(Url $url, ?int $port): Url
{
$parsed = parse_url($url->getValue());
if ($port === null) {
unset($parsed['port']);
} else {
$parsed['port'] = $port;
}
return Url::from($this->buildUrl($parsed));
}
public function withPath(Url $url, string $path): Url
{
$parsed = parse_url($url->getValue());
$parsed['path'] = $path;
return Url::from($this->buildUrl($parsed));
}
public function withQuery(Url $url, array $params): Url
{
$parsed = parse_url($url->getValue());
if (empty($params)) {
unset($parsed['query']);
} else {
$parsed['query'] = http_build_query($params);
}
return Url::from($this->buildUrl($parsed));
}
public function withQueryParameter(Url $url, string $key, string $value): Url
{
$params = $url->getQueryParameters();
$params[$key] = $value;
return $this->withQuery($url, $params);
}
public function withoutQueryParameter(Url $url, string $key): Url
{
$params = $url->getQueryParameters();
unset($params[$key]);
return $this->withQuery($url, $params);
}
public function withFragment(Url $url, ?string $fragment): Url
{
$parsed = parse_url($url->getValue());
if ($fragment === null) {
unset($parsed['fragment']);
} else {
$parsed['fragment'] = $fragment;
}
return Url::from($this->buildUrl($parsed));
}
public function withCredentials(Url $url, string $user, ?string $password = null): Url
{
$parsed = parse_url($url->getValue());
$parsed['user'] = $user;
if ($password !== null) {
$parsed['pass'] = $password;
}
return Url::from($this->buildUrl($parsed));
}
public function withoutCredentials(Url $url): Url
{
$parsed = parse_url($url->getValue());
unset($parsed['user'], $parsed['pass']);
return Url::from($this->buildUrl($parsed));
}
public function resolve(Url $baseUrl, string $relative): Url
{
if (Url::isValid($relative)) {
return Url::from($relative);
}
$base = $baseUrl->scheme . '://' . $baseUrl->host;
if ($baseUrl->port !== null) {
$base .= ':' . $baseUrl->port;
}
if (str_starts_with($relative, '/')) {
return Url::from($base . $relative);
}
$basePath = rtrim(dirname($baseUrl->path), '/');
return Url::from($base . $basePath . '/' . $relative);
}
public function normalize(Url $url): Url
{
$normalized = strtolower($url->scheme) . '://';
$normalized .= strtolower($url->host);
if ($url->port !== null && ! $this->isDefaultPort($url->scheme, $url->port)) {
$normalized .= ':' . $url->port;
}
$path = $url->path;
if (empty($path)) {
$path = '/';
}
$normalized .= $path;
if (! empty($url->query)) {
$normalized .= '?' . $url->query;
}
if (! empty($url->fragment)) {
$normalized .= '#' . $url->fragment;
}
return Url::from($normalized);
}
public function removeTrailingSlash(Url $url): Url
{
if ($url->path !== '/' && str_ends_with($url->path, '/')) {
return $this->withPath($url, rtrim($url->path, '/'));
}
return $url;
}
public function addTrailingSlash(Url $url): Url
{
if (! str_ends_with($url->path, '/')) {
return $this->withPath($url, $url->path . '/');
}
return $url;
}
public function appendPath(Url $url, string $pathSegment): Url
{
$pathSegment = trim($pathSegment, '/');
$currentPath = rtrim($url->path, '/');
return $this->withPath($url, $currentPath . '/' . $pathSegment);
}
public function prependPath(Url $url, string $pathSegment): Url
{
$pathSegment = trim($pathSegment, '/');
$currentPath = ltrim($url->path, '/');
return $this->withPath($url, '/' . $pathSegment . '/' . $currentPath);
}
public function secure(Url $url): Url
{
if ($url->scheme === 'http') {
return $this->withScheme($url, 'https');
}
return $url;
}
public function insecure(Url $url): Url
{
if ($url->scheme === 'https') {
return $this->withScheme($url, 'http');
}
return $url;
}
private function buildUrl(array $parsed): string
{
$url = '';
if (isset($parsed['scheme'])) {
$url .= $parsed['scheme'] . '://';
}
if (isset($parsed['user'])) {
$url .= $parsed['user'];
if (isset($parsed['pass'])) {
$url .= ':' . $parsed['pass'];
}
$url .= '@';
}
if (isset($parsed['host'])) {
$url .= $parsed['host'];
}
if (isset($parsed['port'])) {
$url .= ':' . $parsed['port'];
}
if (isset($parsed['path'])) {
$url .= $parsed['path'];
}
if (isset($parsed['query'])) {
$url .= '?' . $parsed['query'];
}
if (isset($parsed['fragment'])) {
$url .= '#' . $parsed['fragment'];
}
return $url;
}
private function isDefaultPort(string $scheme, int $port): bool
{
$defaults = [
'http' => 80,
'https' => 443,
'ftp' => 21,
'ssh' => 22,
];
return isset($defaults[$scheme]) && $defaults[$scheme] === $port;
}
}

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects\StructuredData;
use App\Framework\Http\Url\Url;
/**
* Article Structured Data
*
* Schema.org Article type for blog posts, news articles, etc.
* Framework-compliant readonly value object with type enum support.
*
* @see https://schema.org/Article
* @psalm-immutable
*/
final readonly class Article implements StructuredData
{
/**
* @param string $headline Article headline (max 110 characters recommended)
* @param Url $url Article URL
* @param \DateTimeImmutable $datePublished Publication date
* @param ArticleType $type Article type variant
* @param Person|null $author Article author
* @param Organization|null $publisher Publisher organization
* @param string|null $description Article description/excerpt
* @param \DateTimeImmutable|null $dateModified Last modification date
* @param Url|null $image Main article image URL
* @param array<string> $keywords Article keywords
*/
public function __construct(
public string $headline,
public Url $url,
public \DateTimeImmutable $datePublished,
public ArticleType $type = ArticleType::ARTICLE,
public ?Person $author = null,
public ?Organization $publisher = null,
public ?string $description = null,
public ?\DateTimeImmutable $dateModified = null,
public ?Url $image = null,
public array $keywords = []
) {
if (mb_strlen($headline) > 110) {
throw new \InvalidArgumentException('Article headline should not exceed 110 characters for optimal SEO');
}
}
public function toJsonLd(): array
{
$data = [
'@context' => 'https://schema.org',
'@type' => $this->type->value,
'headline' => $this->headline,
'url' => $this->url->toString(),
'datePublished' => $this->datePublished->format('c'),
];
if ($this->author !== null) {
$data['author'] = $this->author->toJsonLd();
}
if ($this->publisher !== null) {
$data['publisher'] = $this->publisher->toJsonLd();
}
if ($this->description !== null) {
$data['description'] = $this->description;
}
if ($this->dateModified !== null) {
$data['dateModified'] = $this->dateModified->format('c');
}
if ($this->image !== null) {
$data['image'] = $this->image->toString();
}
if (!empty($this->keywords)) {
$data['keywords'] = implode(', ', $this->keywords);
}
return $data;
}
public function toScript(): string
{
$json = json_encode($this->toJsonLd(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return '<script type="application/ld+json">' . $json . '</script>';
}
/**
* With author (immutable)
*/
public function withAuthor(?Person $author): self
{
return new self(
headline: $this->headline,
url: $this->url,
datePublished: $this->datePublished,
type: $this->type,
author: $author,
publisher: $this->publisher,
description: $this->description,
dateModified: $this->dateModified,
image: $this->image,
keywords: $this->keywords
);
}
/**
* With publisher (immutable)
*/
public function withPublisher(?Organization $publisher): self
{
return new self(
headline: $this->headline,
url: $this->url,
datePublished: $this->datePublished,
type: $this->type,
author: $this->author,
publisher: $publisher,
description: $this->description,
dateModified: $this->dateModified,
image: $this->image,
keywords: $this->keywords
);
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects\StructuredData;
/**
* Article Type Enum
*
* Schema.org Article type variations for different content types.
* Framework-compliant enum for type safety.
*
* @see https://schema.org/Article
*/
enum ArticleType: string
{
case ARTICLE = 'Article';
case NEWS_ARTICLE = 'NewsArticle';
case BLOG_POSTING = 'BlogPosting';
case SCHOLARLY_ARTICLE = 'ScholarlyArticle';
case TECH_ARTICLE = 'TechArticle';
case REPORT = 'Report';
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects\StructuredData;
use App\Framework\Http\Url\Url;
/**
* Organization Structured Data
*
* Schema.org Organization type for companies, brands, etc.
* Framework-compliant readonly value object with type enum support.
*
* @see https://schema.org/Organization
* @psalm-immutable
*/
final readonly class Organization implements StructuredData
{
/**
* @param string $name Organization name
* @param OrganizationType $type Organization type variant
* @param Url|null $url Organization website URL
* @param Url|null $logo Organization logo URL
* @param string|null $description Organization description
* @param array<string> $sameAs Social media profile URLs
*/
public function __construct(
public string $name,
public OrganizationType $type = OrganizationType::ORGANIZATION,
public ?Url $url = null,
public ?Url $logo = null,
public ?string $description = null,
public array $sameAs = []
) {}
public function toJsonLd(): array
{
$data = [
'@context' => 'https://schema.org',
'@type' => $this->type->value,
'name' => $this->name,
];
if ($this->url !== null) {
$data['url'] = $this->url->toString();
}
if ($this->logo !== null) {
$data['logo'] = $this->logo->toString();
}
if ($this->description !== null) {
$data['description'] = $this->description;
}
if (!empty($this->sameAs)) {
$data['sameAs'] = $this->sameAs;
}
return $data;
}
public function toScript(): string
{
$json = json_encode($this->toJsonLd(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return '<script type="application/ld+json">' . $json . '</script>';
}
/**
* With social media profiles (immutable)
*/
public function withSocialProfiles(string ...$urls): self
{
return new self(
name: $this->name,
type: $this->type,
url: $this->url,
logo: $this->logo,
description: $this->description,
sameAs: $urls
);
}
/**
* Add social profile (immutable)
*/
public function addSocialProfile(string $url): self
{
return new self(
name: $this->name,
type: $this->type,
url: $this->url,
logo: $this->logo,
description: $this->description,
sameAs: [...$this->sameAs, $url]
);
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects\StructuredData;
/**
* Organization Type Enum
*
* Schema.org Organization type variations for different entity types.
* Framework-compliant enum for type safety.
*
* @see https://schema.org/Organization
*/
enum OrganizationType: string
{
case ORGANIZATION = 'Organization';
case CORPORATION = 'Corporation';
case LOCAL_BUSINESS = 'LocalBusiness';
case NGO = 'NGO';
case EDUCATIONAL_ORGANIZATION = 'EducationalOrganization';
case GOVERNMENT_ORGANIZATION = 'GovernmentOrganization';
case PERFORMING_GROUP = 'PerformingGroup';
case SPORTS_ORGANIZATION = 'SportsOrganization';
}

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects\StructuredData;
use App\Framework\Http\Url\Url;
/**
* Person Structured Data
*
* Schema.org Person type for individuals, authors, etc.
* Framework-compliant readonly value object.
*
* @see https://schema.org/Person
* @psalm-immutable
*/
final readonly class Person implements StructuredData
{
/**
* @param string $name Person's full name
* @param Url|null $url Person's website/profile URL
* @param Url|null $image Person's photo URL
* @param string|null $jobTitle Job title/occupation
* @param string|null $description Person description/bio
* @param Organization|null $worksFor Employer organization
* @param array<string> $sameAs Social media profile URLs
*/
public function __construct(
public string $name,
public ?Url $url = null,
public ?Url $image = null,
public ?string $jobTitle = null,
public ?string $description = null,
public ?Organization $worksFor = null,
public array $sameAs = []
) {}
public function toJsonLd(): array
{
$data = [
'@context' => 'https://schema.org',
'@type' => 'Person',
'name' => $this->name,
];
if ($this->url !== null) {
$data['url'] = $this->url->toString();
}
if ($this->image !== null) {
$data['image'] = $this->image->toString();
}
if ($this->jobTitle !== null) {
$data['jobTitle'] = $this->jobTitle;
}
if ($this->description !== null) {
$data['description'] = $this->description;
}
if ($this->worksFor !== null) {
$data['worksFor'] = $this->worksFor->toJsonLd();
}
if (!empty($this->sameAs)) {
$data['sameAs'] = $this->sameAs;
}
return $data;
}
public function toScript(): string
{
$json = json_encode($this->toJsonLd(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return '<script type="application/ld+json">' . $json . '</script>';
}
/**
* With employer (immutable)
*/
public function withEmployer(?Organization $organization): self
{
return new self(
name: $this->name,
url: $this->url,
image: $this->image,
jobTitle: $this->jobTitle,
description: $this->description,
worksFor: $organization,
sameAs: $this->sameAs
);
}
/**
* With social profiles (immutable)
*/
public function withSocialProfiles(string ...$urls): self
{
return new self(
name: $this->name,
url: $this->url,
image: $this->image,
jobTitle: $this->jobTitle,
description: $this->description,
worksFor: $this->worksFor,
sameAs: $urls
);
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects\StructuredData;
/**
* Structured Data Value Object (Schema.org Base)
*
* Base interface for all Schema.org structured data types.
* Provides JSON-LD serialization for search engine consumption.
*
* @see https://schema.org/
* @psalm-immutable
*/
interface StructuredData
{
/**
* Convert to JSON-LD array representation
*
* @return array<string, mixed>
*/
public function toJsonLd(): array;
/**
* Render as JSON-LD script tag
*/
public function toScript(): string;
}

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects\StructuredData;
/**
* Structured Data Collection
*
* Collection of StructuredData objects for rendering multiple JSON-LD scripts.
* Framework-compliant readonly value object with variadic constructor.
*
* @psalm-immutable
*/
final readonly class StructuredDataCollection implements \IteratorAggregate, \Countable
{
/** @var array<StructuredData> */
private array $items;
/**
* @param StructuredData ...$items Variadic structured data items
*/
public function __construct(
StructuredData ...$items
) {
$this->items = $items;
}
/**
* Create from array
*
* @param array<StructuredData> $items
*/
public static function fromArray(array $items): self
{
return new self(...$items);
}
/**
* Get count of items
*/
public function count(): int
{
return count($this->items);
}
/**
* Get iterator
*/
public function getIterator(): \ArrayIterator
{
return new \ArrayIterator($this->items);
}
/**
* Check if empty
*/
public function isEmpty(): bool
{
return empty($this->items);
}
/**
* Add item (immutable)
*/
public function add(StructuredData $item): self
{
return new self(...[...$this->items, $item]);
}
/**
* Merge with another collection (immutable)
*/
public function merge(self $other): self
{
return new self(...$this->items, ...$other->items);
}
/**
* Render all items as separate JSON-LD script tags
*/
public function toScripts(): string
{
if ($this->isEmpty()) {
return '';
}
$scripts = array_map(
fn(StructuredData $item) => $item->toScript(),
$this->items
);
return implode("\n", $scripts);
}
/**
* Render all items as single JSON-LD script with array
*/
public function toSingleScript(): string
{
if ($this->isEmpty()) {
return '';
}
$jsonLdArray = array_map(
fn(StructuredData $item) => $item->toJsonLd(),
$this->items
);
$json = json_encode($jsonLdArray, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return '<script type="application/ld+json">' . $json . '</script>';
}
/**
* Convert to string (renders as separate scripts)
*/
public function __toString(): string
{
return $this->toScripts();
}
}

View File

@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects\StructuredData;
use App\Framework\Core\ValueObjects\LinkCollection;
use App\Framework\Http\Url\Url;
/**
* WebPage Structured Data
*
* Schema.org WebPage type for page-level metadata.
* Framework-compliant readonly value object with type enum support.
*
* @see https://schema.org/WebPage
* @psalm-immutable
*/
final readonly class WebPage implements StructuredData
{
/**
* @param Url $url Page URL
* @param string $name Page title/name
* @param WebPageType $type WebPage type variant
* @param string|null $description Page description
* @param LinkCollection|null $breadcrumb Breadcrumb navigation
* @param \DateTimeImmutable|null $datePublished Publication date
* @param \DateTimeImmutable|null $dateModified Last modification date
* @param string|null $inLanguage Language code (e.g., 'en', 'de')
* @param array<string> $keywords SEO keywords
*/
public function __construct(
public Url $url,
public string $name,
public WebPageType $type = WebPageType::WEB_PAGE,
public ?string $description = null,
public ?LinkCollection $breadcrumb = null,
public ?\DateTimeImmutable $datePublished = null,
public ?\DateTimeImmutable $dateModified = null,
public ?string $inLanguage = null,
public array $keywords = []
) {}
public function toJsonLd(): array
{
$data = [
'@context' => 'https://schema.org',
'@type' => $this->type->value,
'url' => $this->url->toString(),
'name' => $this->name,
];
if ($this->description !== null) {
$data['description'] = $this->description;
}
if ($this->breadcrumb !== null && !$this->breadcrumb->isEmpty()) {
$data['breadcrumb'] = $this->buildBreadcrumbList();
}
if ($this->datePublished !== null) {
$data['datePublished'] = $this->datePublished->format('c');
}
if ($this->dateModified !== null) {
$data['dateModified'] = $this->dateModified->format('c');
}
if ($this->inLanguage !== null) {
$data['inLanguage'] = $this->inLanguage;
}
if (!empty($this->keywords)) {
$data['keywords'] = implode(', ', $this->keywords);
}
return $data;
}
public function toScript(): string
{
$json = json_encode($this->toJsonLd(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return '<script type="application/ld+json">' . $json . '</script>';
}
/**
* Build breadcrumb list for JSON-LD
*/
private function buildBreadcrumbList(): array
{
$items = [];
$position = 1;
foreach ($this->breadcrumb as $link) {
$href = $link instanceof \App\Framework\Core\ValueObjects\AccessibleLink
? $link->getHref()
: $link->getHref();
$name = $link instanceof \App\Framework\Core\ValueObjects\AccessibleLink
? ($link->baseLink->text ?? $href)
: ($link->text ?? $href);
$items[] = [
'@type' => 'ListItem',
'position' => $position,
'name' => $name,
'item' => $href
];
$position++;
}
return [
'@type' => 'BreadcrumbList',
'itemListElement' => $items
];
}
/**
* With description (immutable)
*/
public function withDescription(?string $description): self
{
return new self(
url: $this->url,
name: $this->name,
type: $this->type,
description: $description,
breadcrumb: $this->breadcrumb,
datePublished: $this->datePublished,
dateModified: $this->dateModified,
inLanguage: $this->inLanguage,
keywords: $this->keywords
);
}
/**
* With breadcrumb (immutable)
*/
public function withBreadcrumb(?LinkCollection $breadcrumb): self
{
return new self(
url: $this->url,
name: $this->name,
type: $this->type,
description: $this->description,
breadcrumb: $breadcrumb,
datePublished: $this->datePublished,
dateModified: $this->dateModified,
inLanguage: $this->inLanguage,
keywords: $this->keywords
);
}
/**
* With keywords (immutable)
*/
public function withKeywords(string ...$keywords): self
{
return new self(
url: $this->url,
name: $this->name,
type: $this->type,
description: $this->description,
breadcrumb: $this->breadcrumb,
datePublished: $this->datePublished,
dateModified: $this->dateModified,
inLanguage: $this->inLanguage,
keywords: $keywords
);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects\StructuredData;
/**
* WebPage Type Enum
*
* Schema.org WebPage type variations for different page types.
* Framework-compliant enum for type safety.
*
* @see https://schema.org/WebPage
*/
enum WebPageType: string
{
case WEB_PAGE = 'WebPage';
case ABOUT_PAGE = 'AboutPage';
case CHECKOUT_PAGE = 'CheckoutPage';
case COLLECTION_PAGE = 'CollectionPage';
case CONTACT_PAGE = 'ContactPage';
case FAQ_PAGE = 'FAQPage';
case ITEM_PAGE = 'ItemPage';
case PROFILE_PAGE = 'ProfilePage';
case SEARCH_RESULTS_PAGE = 'SearchResultsPage';
}

View File

@@ -44,6 +44,14 @@ final readonly class Timestamp
return new SystemClock()->time();
}
/**
* Create timestamp from unix timestamp
*/
public static function fromTimestamp(int $timestamp): self
{
return new self((float) $timestamp);
}
/**
* Create timestamp from DateTimeImmutable
*/

View File

@@ -1,291 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
use InvalidArgumentException;
final readonly class Url
{
public string $scheme;
public string $host;
public ?int $port;
public string $path;
public string $query;
public string $fragment;
public string $user;
public string $password;
public function __construct(
private string $value
) {
$this->validate();
$this->parseComponents();
}
// Factory Methods
public static function from(string $url): self
{
return new self($url);
}
public static function parse(string $url): self
{
$url = trim($url);
// Add protocol if missing
if (! preg_match('/^https?:\/\//', $url)) {
$url = 'https://' . $url;
}
return new self($url);
}
public static function build(
string $scheme,
string $host,
?int $port = null,
string $path = '',
array $query = [],
?string $fragment = null
): self {
$url = $scheme . '://' . $host;
if ($port !== null && ! self::isDefaultPort($scheme, $port)) {
$url .= ':' . $port;
}
if ($path && ! str_starts_with($path, '/')) {
$path = '/' . $path;
}
$url .= $path;
if (! empty($query)) {
$url .= '?' . http_build_query($query);
}
if ($fragment !== null) {
$url .= '#' . $fragment;
}
return new self($url);
}
// Validation
public static function isValid(string $url): bool
{
try {
new self($url);
return true;
} catch (InvalidArgumentException) {
return false;
}
}
// Getters
public function getValue(): string
{
return $this->value;
}
public function getQueryParameters(): array
{
if (empty($this->query)) {
return [];
}
parse_str($this->query, $params);
return $params;
}
public function getEffectivePort(): int
{
return $this->port ?? $this->getDefaultPort();
}
public function getPortEnum(): ?Port
{
if ($this->port === null) {
return Port::forScheme($this->scheme);
}
return Port::tryFrom($this->port);
}
// URL checks
public function isSecure(): bool
{
return $this->scheme === 'https' || $this->getPortEnum()?->isSecure() === true;
}
public function isHttp(): bool
{
return in_array($this->scheme, ['http', 'https'], true);
}
public function isLocal(): bool
{
return in_array($this->host, ['localhost', '127.0.0.1', '::1'], true) ||
str_ends_with($this->host, '.local') ||
preg_match('/^192\.168\./', $this->host) ||
preg_match('/^10\./', $this->host) ||
preg_match('/^172\.(1[6-9]|2[0-9]|3[01])\./', $this->host);
}
public function isAbsolute(): bool
{
return ! empty($this->scheme);
}
public function isRelative(): bool
{
return ! $this->isAbsolute();
}
public function hasCredentials(): bool
{
return ! empty($this->user);
}
public function getOrigin(): self
{
$origin = $this->scheme . '://' . $this->host;
if ($this->port !== null && ! self::isDefaultPort($this->scheme, $this->port)) {
$origin .= ':' . $this->port;
}
return new self($origin);
}
// Comparison
public function equals(Url $other): bool
{
return $this->value === $other->value;
}
public function isSameOrigin(Url $other): bool
{
return $this->getOrigin()->equals($other->getOrigin());
}
public function isSameDomain(Url $other): bool
{
return strtolower($this->host) === strtolower($other->host);
}
// String representation
public function toString(): string
{
return $this->value;
}
public function __toString(): string
{
return $this->value;
}
// Private methods
private function validate(): void
{
if (empty($this->value)) {
throw new InvalidArgumentException('URL cannot be empty');
}
if (strlen($this->value) > 2048) {
throw new InvalidArgumentException('URL too long (max 2048 characters)');
}
// Trim whitespace
$trimmed = trim($this->value);
if ($trimmed !== $this->value) {
throw new InvalidArgumentException("URL cannot have leading or trailing whitespace: {$this->value}");
}
$parsed = parse_url($this->value);
if ($parsed === false) {
throw new InvalidArgumentException("Invalid URL: {$this->value}");
}
if (! isset($parsed['scheme'], $parsed['host'])) {
throw new InvalidArgumentException("URL must have scheme and host: {$this->value}");
}
// Validate scheme
$scheme = strtolower($parsed['scheme']);
if (! in_array($scheme, ['http', 'https', 'ftp', 'ftps', 'ssh', 'file', 'data'], true)) {
throw new InvalidArgumentException("Unsupported URL scheme: {$parsed['scheme']}");
}
// Validate host
if (empty($parsed['host']) || strlen($parsed['host']) > 253) {
throw new InvalidArgumentException("Invalid host: {$parsed['host']}");
}
// Check for invalid characters in host
if (preg_match('/[\s<>"\']/', $parsed['host'])) {
throw new InvalidArgumentException("Host contains invalid characters: {$parsed['host']}");
}
// Check for consecutive dots in host
if (strpos($parsed['host'], '..') !== false) {
throw new InvalidArgumentException("Host cannot contain consecutive dots: {$parsed['host']}");
}
// Check for host starting or ending with dot or dash
if (preg_match('/^[.-]|[.-]$/', $parsed['host'])) {
throw new InvalidArgumentException("Host cannot start or end with dot or dash: {$parsed['host']}");
}
// Validate IP addresses (if host looks like an IP)
if (preg_match('/^\d+\.\d+\.\d+\.\d+$/', $parsed['host'])) {
if (! filter_var($parsed['host'], FILTER_VALIDATE_IP)) {
throw new InvalidArgumentException("Invalid IP address: {$parsed['host']}");
}
}
if (isset($parsed['port']) && ! Port::isValidPort($parsed['port'])) {
throw new InvalidArgumentException("Invalid port: {$parsed['port']}");
}
// Additional validation for specific URL patterns that should be rejected
if (in_array($this->value, ['http://', 'https://', 'ftp://'], true)) {
throw new InvalidArgumentException("URL cannot be just a scheme: {$this->value}");
}
}
private function parseComponents(): void
{
$parsed = parse_url($this->value);
$this->scheme = $parsed['scheme'] ?? '';
$this->host = $parsed['host'] ?? '';
$this->port = isset($parsed['port']) ? (int) $parsed['port'] : null;
$this->path = $parsed['path'] ?? '/';
$this->query = $parsed['query'] ?? '';
$this->fragment = $parsed['fragment'] ?? '';
$this->user = $parsed['user'] ?? '';
$this->password = $parsed['pass'] ?? '';
}
private function getDefaultPort(): int
{
return Port::forScheme($this->scheme)?->value ?? 80;
}
private static function isDefaultPort(string $scheme, int $port): bool
{
$defaultPort = Port::forScheme($scheme);
return $defaultPort?->value === $port;
}
}