docs: consolidate documentation into organized structure

- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -91,8 +91,6 @@ final readonly class AppBootstrapper
}
});
}
#error_log("AppBootstrapper: 🚀 Context detected as {$executionContext->getType()->value}");
#error_log("AppBootstrapper: Debug - isProduction: " . ($envType->isProduction() ? 'true' : 'false'));
}
public function bootstrapWeb(): ApplicationInterface

View File

@@ -11,6 +11,7 @@ use App\Framework\Core\Events\ApplicationBooted;
use App\Framework\Core\Events\BeforeEmitResponse;
use App\Framework\Core\Events\BeforeHandleRequest;
use App\Framework\Core\Events\EventDispatcherInterface;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\DI\Container;
use App\Framework\Http\MiddlewareManagerInterface;
use App\Framework\Http\Request;
@@ -81,7 +82,15 @@ final readonly class Application implements ApplicationInterface
*/
private function handleRequest(Request $request): Response
{
$this->event(new BeforeHandleRequest());
$this->event(new BeforeHandleRequest(
request: $request,
context: [
'route' => $request->path,
'method' => $request->method->value,
'user_agent' => $request->server->getUserAgent()?->toString(),
'client_ip' => (string) $request->server->getClientIp()
]
));
$response = $this->performanceCollector->measure(
'handle_request',
@@ -91,9 +100,28 @@ final readonly class Application implements ApplicationInterface
}
);
#$response = $this->middlewareManager->chain->handle($request);
// Duration aus PerformanceCollector abrufen
$requestMetrics = $this->performanceCollector->getMetrics(PerformanceCategory::SYSTEM);
$processingTime = Duration::zero();
$this->event(new AfterHandleRequest());
if (isset($requestMetrics['handle_request'])) {
$measurements = $requestMetrics['handle_request']->getMeasurements();
$latestMeasurement = $measurements->getLast();
if ($latestMeasurement !== null) {
$processingTime = $latestMeasurement->getDuration();
}
}
$this->event(new AfterHandleRequest(
request: $request,
response: $response,
processingTime: $processingTime,
context: [
'status_code' => $response->status->value,
'content_length' => strlen($response->body),
'memory_peak' => memory_get_peak_usage(true)
]
));
return $response;
}
@@ -103,11 +131,40 @@ final readonly class Application implements ApplicationInterface
*/
private function emitResponse(Response $response): void
{
$this->event(new BeforeEmitResponse());
$request = $this->container->get(Request::class);
$this->event(new BeforeEmitResponse(
request: $request,
response: $response,
context: [
'content_type' => $response->headers->getFirst('Content-Type', 'text/html'),
'cache_control' => $response->headers->getFirst('Cache-Control', 'no-cache')
]
));
$this->responseEmitter->emit($response);
$this->event(new AfterEmitResponse());
// Gesamtzeit aus PerformanceCollector abrufen
$systemMetrics = $this->performanceCollector->getMetrics(PerformanceCategory::SYSTEM);
$totalTime = Duration::zero();
if (isset($systemMetrics['handle_request'])) {
$measurements = $systemMetrics['handle_request']->getMeasurements();
$latestMeasurement = $measurements->getLast();
if ($latestMeasurement !== null) {
$totalTime = $latestMeasurement->getDuration();
}
}
$this->event(new AfterEmitResponse(
request: $request,
response: $response,
totalProcessingTime: $totalTime,
context: [
'bytes_sent' => strlen($response->body),
'final_status' => $response->status->value
]
));
}
private function event(object $event): void

View File

@@ -141,6 +141,24 @@ final readonly class ContainerBootstrapper
$container->instance(PathProvider::class, new PathProvider($basePath));
$container->instance(ResponseEmitter::class, new ResponseEmitter());
$container->instance(Clock::class, new SystemClock());
// TEMPORARY FIX: Manual RequestFactory binding until Discovery issue is resolved
$container->singleton(\App\Framework\Http\Request::class, function ($container) {
error_log("ContainerBootstrapper: Creating Request singleton");
// Get Cache from container (it was just registered above)
$frameworkCache = $container->get(\App\Framework\Cache\Cache::class);
$parserCache = new \App\Framework\Http\Parser\ParserCache($frameworkCache);
$parser = new \App\Framework\Http\Parser\HttpRequestParser($parserCache);
$factory = new \App\Framework\Http\RequestFactory($parser);
error_log("ContainerBootstrapper: About to call factory->createFromGlobals()");
$request = $factory->createFromGlobals();
error_log("ContainerBootstrapper: Request created successfully");
return $request;
});
}
/**

View File

@@ -71,18 +71,33 @@ final readonly class Base32Encoder
$encoded .= $alphabet->getCharacterAt($index);
}
// Add padding based on input length (if alphabet uses padding)
if ($alphabet->usesPadding() && $chunkLength < 5) {
$paddingCount = match ($chunkLength) {
1 => 6,
2 => 4,
3 => 3,
4 => 1,
default => 0
};
// Handle incomplete chunks
if ($chunkLength < 5) {
if ($alphabet->usesPadding()) {
// Add padding for RFC3548
$paddingCount = match ($chunkLength) {
1 => 6,
2 => 4,
3 => 3,
4 => 1,
default => 0
};
$encoded = substr($encoded, 0, -$paddingCount);
$encoded .= str_repeat(self::PADDING, $paddingCount);
$encoded = substr($encoded, 0, -$paddingCount);
$encoded .= str_repeat(self::PADDING, $paddingCount);
} else {
// For Crockford (no padding), truncate to correct length
$actualCharCount = match ($chunkLength) {
1 => 2, // 1 byte = 8 bits = 1.6 chars -> 2 chars
2 => 4, // 2 bytes = 16 bits = 3.2 chars -> 4 chars
3 => 5, // 3 bytes = 24 bits = 4.8 chars -> 5 chars
4 => 7, // 4 bytes = 32 bits = 6.4 chars -> 7 chars
default => 8
};
// Remove excess characters for incomplete chunk
$encoded = substr($encoded, 0, strlen($encoded) - (8 - $actualCharCount));
}
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\Events;
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
{
public readonly Timestamp $timestamp;
public function __construct(
public Request $request,
public Response $response,
public ClassName $controllerClass,
public MethodName $methodName,
public Duration $executionTime,
?Timestamp $timestamp = null,
public array $context = []
) {
$this->timestamp = $timestamp ?? Timestamp::now();
}
}

View File

@@ -4,6 +4,22 @@ declare(strict_types=1);
namespace App\Framework\Core\Events;
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
{
public readonly Timestamp $timestamp;
public function __construct(
public Request $request,
public Response $response,
public Duration $totalProcessingTime,
?Timestamp $timestamp = null,
public array $context = []
) {
$this->timestamp = $timestamp ?? Timestamp::now();
}
}

View File

@@ -4,6 +4,22 @@ declare(strict_types=1);
namespace App\Framework\Core\Events;
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
{
public readonly Timestamp $timestamp;
public function __construct(
public Request $request,
public Response $response,
public Duration $processingTime,
?Timestamp $timestamp = null,
public array $context = []
) {
$this->timestamp = $timestamp ?? Timestamp::now();
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\Events;
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
{
public readonly Timestamp $timestamp;
public function __construct(
public Request $request,
public Response $response,
public ClassName $middlewareClass,
public Duration $executionTime,
?Timestamp $timestamp = null,
public array $context = []
) {
$this->timestamp = $timestamp ?? Timestamp::now();
}
}

View File

@@ -0,0 +1,25 @@
<?php
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;
final readonly class AfterRouteMatching
{
public readonly Timestamp $timestamp;
public function __construct(
public Request $request,
public ?string $routeName,
public array $routeParameters,
public Duration $matchingTime,
?Timestamp $timestamp = null,
public array $context = []
) {
$this->timestamp = $timestamp ?? Timestamp::now();
}
}

View File

@@ -0,0 +1,26 @@
<?php
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;
final readonly class BeforeControllerExecution
{
public readonly Timestamp $timestamp;
public function __construct(
public Request $request,
public ClassName $controllerClass,
public MethodName $methodName,
public array $parameters,
?Timestamp $timestamp = null,
public array $context = []
) {
$this->timestamp = $timestamp ?? Timestamp::now();
}
}

View File

@@ -4,6 +4,20 @@ declare(strict_types=1);
namespace App\Framework\Core\Events;
use App\Framework\Http\Request;
use App\Framework\Http\Response;
use App\Framework\Core\ValueObjects\Timestamp;
final readonly class BeforeEmitResponse
{
public readonly Timestamp $timestamp;
public function __construct(
public Request $request,
public Response $response,
?Timestamp $timestamp = null,
public array $context = []
) {
$this->timestamp = $timestamp ?? Timestamp::now();
}
}

View File

@@ -4,6 +4,18 @@ declare(strict_types=1);
namespace App\Framework\Core\Events;
use App\Framework\Http\Request;
use App\Framework\Core\ValueObjects\Timestamp;
final readonly class BeforeHandleRequest
{
public readonly Timestamp $timestamp;
public function __construct(
public Request $request,
?Timestamp $timestamp = null,
public array $context = []
) {
$this->timestamp = $timestamp ?? Timestamp::now();
}
}

View File

@@ -0,0 +1,24 @@
<?php
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;
final readonly class BeforeMiddlewareExecution
{
public readonly Timestamp $timestamp;
public function __construct(
public Request $request,
public ClassName $middlewareClass,
public int $priority,
?Timestamp $timestamp = null,
public array $context = []
) {
$this->timestamp = $timestamp ?? Timestamp::now();
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\Events;
use App\Framework\Http\Request;
use App\Framework\Core\ValueObjects\Timestamp;
final readonly class BeforeRouteMatching
{
public readonly Timestamp $timestamp;
public function __construct(
public Request $request,
?Timestamp $timestamp = null,
public array $context = []
) {
$this->timestamp = $timestamp ?? Timestamp::now();
}
}

View File

@@ -9,7 +9,7 @@ namespace App\Framework\Core;
*/
final class PathProvider
{
private string $basePath;
private readonly string $basePath;
private array $resolvedPaths = [];
@@ -127,7 +127,22 @@ final class PathProvider
*/
public function getCachePath(string $path = ''): string
{
return $this->basePath . '/cache/' . ltrim($path, '/');
return $this->basePath . '/storage/cache/' . ltrim($path, '/');
}
public function getLogPath(string $path = ''): string
{
return $this->basePath . '/storage/logs/' . ltrim($path, '/');
}
public function getTempPath(string $path = ''): string
{
return $this->basePath . '/storage/temp/' . ltrim($path, '/');
}
public function getUploadsPath(string $path = ''): string
{
return $this->basePath . '/storage/uploads/' . ltrim($path, '/');
}
/**
@@ -137,4 +152,14 @@ final class PathProvider
{
return $this->basePath . '/src/' . ltrim($path, '/');
}
public function getPublicPath(string $path = ''): string
{
return $this->basePath . '/public/' . ltrim($path, '/');
}
public function getStoragePath(string $path = ''): string
{
return $this->basePath . '/storage/' . ltrim($path, '/');
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Framework\Core\Performance;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Performance\Contracts\PerformanceCollectorInterface;
use App\Framework\Performance\PerformanceCategory;
@@ -92,11 +93,11 @@ final class ContainerPerformanceMonitor
$performanceResult = $this->endOperation('interface_binding');
if (! $performanceResult->isWithinThreshold) {
$this->logger?->warning('Slow container binding detected', [
$this->logger?->warning('Slow container binding detected', LogContext::withData([
'interface' => $interface,
'duration' => $performanceResult->duration,
'threshold' => $performanceResult->threshold,
]);
]));
}
return $result;
@@ -171,13 +172,13 @@ final class ContainerPerformanceMonitor
*/
private function logPerformanceIssue(ContainerPerformanceResult $result): void
{
$this->logger?->warning('Container performance threshold exceeded', [
$this->logger?->warning('Container performance threshold exceeded', LogContext::withData([
'operation' => $result->operation,
'duration' => $result->duration,
'threshold' => $result->threshold,
'slowness_factor' => $result->duration / $result->threshold,
'recommendation' => $result->recommendation,
]);
]));
}
/**

View File

@@ -9,6 +9,7 @@ use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
use App\Framework\Router\CompiledPattern;
use App\Framework\Router\CompiledRoutes;
use App\Framework\Router\RouteData;
use App\Framework\Router\RouteNameInterface;
use App\Framework\Router\ValueObjects\MethodParameter;
use App\Framework\Router\ValueObjects\ParameterCollection;
use App\Framework\Router\ValueObjects\SubdomainPattern;
@@ -36,8 +37,8 @@ final readonly class RouteCompiler
// Extract route data directly from the Route attribute
$method = strtoupper($routeAttribute->method->value);
$path = $routeAttribute->path;
$routeName = $routeAttribute->name;
$path = $routeAttribute->getPathAsString();
$routeName = $this->extractRouteName($routeAttribute->name);
// Process subdomain patterns
$subdomainPatterns = SubdomainPattern::fromInput($routeAttribute->subdomain);
@@ -211,15 +212,21 @@ final readonly class RouteCompiler
$pattern = $this->stripAnchors($route->regex);
$patterns[] = "({$pattern})";
// Route-Gruppe Index merken
$routeGroupIndex = $currentIndex++;
// This route's main capture group index
$routeGroupIndex = $currentIndex;
// Parameter-Mapping berechnen
// Parameter-Mapping berechnen - parameters are INSIDE the route pattern
// So their indices are routeGroupIndex + 1, routeGroupIndex + 2, etc.
$paramMap = [];
$paramOffset = 1; // Parameters start right after the route's capture group
foreach ($route->paramNames as $paramName) {
$paramMap[$paramName] = $currentIndex++;
$paramMap[$paramName] = $routeGroupIndex + $paramOffset++;
}
// Now advance currentIndex past ALL capture groups in this route's pattern
// That's 1 (the route group) + count(paramNames) (the parameters inside it)
$currentIndex += 1 + count($route->paramNames);
$routeData[$globalIndex] = new RouteData(
route: $route,
paramMap: $paramMap,
@@ -313,4 +320,16 @@ final readonly class RouteCompiler
return $regex;
}
/**
* Extract route name as string from enum or string
*/
private function extractRouteName(RouteNameInterface|string|null $routeName): string
{
if ($routeName instanceof RouteNameInterface) {
return $routeName->value;
}
return (string) ($routeName ?? '');
}
}

View File

@@ -4,6 +4,7 @@ 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,220 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
/**
* Complete address combining street, city, postal code and country
* Immutable value object for address handling
*/
final readonly class Address
{
public function __construct(
public Street $street,
public City $city,
public PostalCode $postalCode,
public CountryCode $country
) {
}
public static function create(
Street $street,
City $city,
PostalCode $postalCode,
CountryCode $country
): self {
return new self($street, $city, $postalCode, $country);
}
public static function fromParts(
string $streetName,
string $streetNumber,
string $cityName,
string $postalCode,
string $countryCode,
?string $streetAddition = null,
?string $cityState = null
): self {
$country = CountryCode::fromString($countryCode);
return new self(
new Street($streetName, $streetNumber, $streetAddition),
new City($cityName, $cityState, $country),
new PostalCode($postalCode, $country),
$country
);
}
public function withStreet(Street $street): self
{
return new self($street, $this->city, $this->postalCode, $this->country);
}
public function withCity(City $city): self
{
return new self($this->street, $city, $this->postalCode, $this->country);
}
public function withPostalCode(PostalCode $postalCode): self
{
return new self($this->street, $this->city, $postalCode, $this->country);
}
public function withCountry(CountryCode $country): self
{
// Update postal code and city country references
$newPostalCode = new PostalCode($this->postalCode->code, $country);
$newCity = $this->city->withCountry($country);
return new self($this->street, $newCity, $newPostalCode, $country);
}
public function format(string $style = 'multiline'): string
{
return match($style) {
'single_line' => $this->formatSingleLine(),
'multiline' => $this->formatMultiline(),
'postal' => $this->formatPostal(),
default => $this->formatMultiline()
};
}
private function formatSingleLine(): string
{
return sprintf(
'%s, %s %s, %s',
$this->street->format(),
$this->postalCode->format(),
$this->city->name,
$this->country->getCountryName() ?? $this->country->value
);
}
private function formatMultiline(): string
{
$lines = [
$this->street->format(),
$this->postalCode->format() . ' ' . $this->city->format(false),
];
if ($this->city->hasState()) {
$lines[1] = $this->postalCode->format() . ' ' . $this->city->name . ', ' . $this->city->state;
}
$lines[] = $this->country->getCountryName() ?? $this->country->value;
return implode("\n", $lines);
}
private function formatPostal(): string
{
// Format suitable for postal services
return match($this->country->value) {
'US' => $this->formatUSPostal(),
'GB' => $this->formatUKPostal(),
'DE' => $this->formatGermanPostal(),
default => $this->formatMultiline()
};
}
private function formatUSPostal(): string
{
$state = $this->city->state ?? '';
return sprintf(
"%s\n%s, %s %s\n%s",
$this->street->format(),
$this->city->name,
$state,
$this->postalCode->format(),
'USA'
);
}
private function formatUKPostal(): string
{
return sprintf(
"%s\n%s\n%s\n%s",
$this->street->format(),
$this->city->name,
$this->postalCode->format(),
'United Kingdom'
);
}
private function formatGermanPostal(): string
{
return sprintf(
"%s\n%s %s\n%s",
$this->street->format('number_first'),
$this->postalCode->format(),
$this->city->name,
'Deutschland'
);
}
public function getRegion(): ?string
{
return $this->city->state;
}
public function isInCountry(CountryCode $country): bool
{
return $this->country->value === $country->value;
}
public function isInSameCity(self $other): bool
{
return $this->city->equals($other->city)
&& $this->country->value === $other->country->value;
}
public function isInSamePostalCode(self $other): bool
{
return $this->postalCode->equals($other->postalCode);
}
public function equals(self $other): bool
{
return $this->street->equals($other->street)
&& $this->city->equals($other->city)
&& $this->postalCode->equals($other->postalCode)
&& $this->country->value === $other->country->value;
}
public function toString(): string
{
return $this->format('single_line');
}
public function __toString(): string
{
return $this->format('single_line');
}
public function toArray(): array
{
return [
'street' => $this->street->toArray(),
'city' => $this->city->toArray(),
'postal_code' => $this->postalCode->toArray(),
'country' => $this->country->value,
'formatted' => [
'single_line' => $this->format('single_line'),
'multiline' => $this->format('multiline'),
'postal' => $this->format('postal'),
],
];
}
public static function fromArray(array $data): self
{
return new self(
Street::fromArray($data['street']),
City::fromArray($data['city']),
PostalCode::fromArray($data['postal_code']),
CountryCode::fromString($data['country'])
);
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
use InvalidArgumentException;
/**
* Represents a city name with optional state/province
* Immutable value object for address handling
*/
final readonly class City
{
public function __construct(
public string $name,
public ?string $state = null,
public ?CountryCode $country = null
) {
if (trim($name) === '') {
throw new InvalidArgumentException('City name cannot be empty');
}
}
public static function create(string $name, ?string $state = null, ?CountryCode $country = null): self
{
return new self($name, $state, $country);
}
public function withState(string $state): self
{
return new self($this->name, $state, $this->country);
}
public function withCountry(CountryCode $country): self
{
return new self($this->name, $this->state, $country);
}
public function withoutState(): self
{
return new self($this->name, null, $this->country);
}
public function format(bool $includeState = true, bool $includeCountry = false): string
{
$formatted = $this->name;
if ($includeState && $this->state !== null && trim($this->state) !== '') {
$formatted .= ', ' . $this->state;
}
if ($includeCountry && $this->country !== null) {
$formatted .= ', ' . ($this->country->getCountryName() ?? $this->country->value);
}
return $formatted;
}
public function getDisplayName(): string
{
return $this->format(true, false);
}
public function getFullName(): string
{
return $this->format(true, true);
}
public function hasState(): bool
{
return $this->state !== null && trim($this->state) !== '';
}
public function hasCountry(): bool
{
return $this->country !== null;
}
public function equals(self $other): bool
{
return $this->name === $other->name
&& $this->state === $other->state
&& (
($this->country === null && $other->country === null) ||
($this->country !== null && $other->country !== null && $this->country->value === $other->country->value)
);
}
public function toString(): string
{
return $this->getDisplayName();
}
public function __toString(): string
{
return $this->getDisplayName();
}
public function toArray(): array
{
return [
'name' => $this->name,
'state' => $this->state,
'country' => $this->country?->value,
'display_name' => $this->getDisplayName(),
'full_name' => $this->getFullName(),
];
}
public static function fromArray(array $data): self
{
$country = isset($data['country'])
? CountryCode::fromString($data['country'])
: null;
return new self(
$data['name'],
$data['state'] ?? null,
$country
);
}
}

View File

@@ -96,14 +96,19 @@ final readonly class ClassName
/**
* Check if class exists
*
* @param bool $autoload Whether to trigger autoloading (default: true)
* Set to false during discovery to prevent constructor execution
*/
public function exists(): bool
public function exists(bool $autoload = true): bool
{
if (empty($this->fullyQualified)) {
return false;
}
return class_exists($this->fullyQualified) || interface_exists($this->fullyQualified) || trait_exists($this->fullyQualified);
return class_exists($this->fullyQualified, $autoload)
|| interface_exists($this->fullyQualified, $autoload)
|| trait_exists($this->fullyQualified, $autoload);
}
/**

View File

@@ -33,6 +33,7 @@ final readonly class CountryCode
'TG', 'TH', 'TJ', 'TK', 'TL', 'TM', 'TN', 'TO', 'TR', 'TT', 'TV', 'TW',
'TZ', 'UA', 'UG', 'UM', 'US', 'UY', 'UZ', 'VA', 'VC', 'VE', 'VG', 'VI',
'VN', 'VU', 'WF', 'WS', 'YE', 'YT', 'ZA', 'ZM', 'ZW',
'XX', 'ZZ', 'A1', 'A2',
];
private const array COUNTRY_NAMES = [

View File

@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
/**
* ISO 4217 Currency Codes with metadata
* Immutable enum for type-safe currency handling
*/
enum Currency: string
{
case EUR = 'EUR';
case USD = 'USD';
case GBP = 'GBP';
case CHF = 'CHF';
case JPY = 'JPY';
case CNY = 'CNY';
case AUD = 'AUD';
case CAD = 'CAD';
case SEK = 'SEK';
case NOK = 'NOK';
case DKK = 'DKK';
case PLN = 'PLN';
case CZK = 'CZK';
case HUF = 'HUF';
case RON = 'RON';
case BGN = 'BGN';
case HRK = 'HRK';
case RUB = 'RUB';
case TRY = 'TRY';
case INR = 'INR';
case BRL = 'BRL';
case ZAR = 'ZAR';
case KRW = 'KRW';
case SGD = 'SGD';
case NZD = 'NZD';
case MXN = 'MXN';
case BTC = 'BTC';
case ETH = 'ETH';
public function getSymbol(): string
{
return match($this) {
self::EUR => '€',
self::USD => '$',
self::GBP => '£',
self::CHF => 'CHF',
self::JPY => '¥',
self::CNY => '¥',
self::AUD => 'A$',
self::CAD => 'C$',
self::SEK => 'kr',
self::NOK => 'kr',
self::DKK => 'kr',
self::PLN => 'zł',
self::CZK => 'Kč',
self::HUF => 'Ft',
self::RON => 'lei',
self::BGN => 'лв',
self::HRK => 'kn',
self::RUB => '₽',
self::TRY => '₺',
self::INR => '₹',
self::BRL => 'R$',
self::ZAR => 'R',
self::KRW => '₩',
self::SGD => 'S$',
self::NZD => 'NZ$',
self::MXN => '$',
self::BTC => '₿',
self::ETH => 'Ξ',
};
}
public function getDecimals(): int
{
return match($this) {
self::JPY, self::KRW => 0,
self::BTC => 8,
self::ETH => 18,
default => 2,
};
}
public function getName(): string
{
return match($this) {
self::EUR => 'Euro',
self::USD => 'US Dollar',
self::GBP => 'British Pound',
self::CHF => 'Swiss Franc',
self::JPY => 'Japanese Yen',
self::CNY => 'Chinese Yuan',
self::AUD => 'Australian Dollar',
self::CAD => 'Canadian Dollar',
self::SEK => 'Swedish Krona',
self::NOK => 'Norwegian Krone',
self::DKK => 'Danish Krone',
self::PLN => 'Polish Zloty',
self::CZK => 'Czech Koruna',
self::HUF => 'Hungarian Forint',
self::RON => 'Romanian Leu',
self::BGN => 'Bulgarian Lev',
self::HRK => 'Croatian Kuna',
self::RUB => 'Russian Ruble',
self::TRY => 'Turkish Lira',
self::INR => 'Indian Rupee',
self::BRL => 'Brazilian Real',
self::ZAR => 'South African Rand',
self::KRW => 'South Korean Won',
self::SGD => 'Singapore Dollar',
self::NZD => 'New Zealand Dollar',
self::MXN => 'Mexican Peso',
self::BTC => 'Bitcoin',
self::ETH => 'Ethereum',
};
}
public function getMinorUnit(): int
{
return 10 ** $this->getDecimals();
}
public function formatAmount(int $amountInMinorUnits): string
{
$decimals = $this->getDecimals();
$amount = $amountInMinorUnits / $this->getMinorUnit();
$formatted = number_format($amount, $decimals, '.', ',');
return $this->getSymbol() . $formatted;
}
public function equals(self $other): bool
{
return $this === $other;
}
public function isNot(self $other): bool
{
return $this !== $other;
}
public function isCrypto(): bool
{
return match($this) {
self::BTC, self::ETH => true,
default => false,
};
}
public function isFiat(): bool
{
return ! $this->isCrypto();
}
public function isEurozone(): bool
{
return $this === self::EUR;
}
public static function fromCode(string $code): ?self
{
return self::tryFrom(strtoupper($code));
}
public function toArray(): array
{
return [
'code' => $this->value,
'symbol' => $this->getSymbol(),
'decimals' => $this->getDecimals(),
'name' => $this->getName(),
];
}
}

View File

@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\ErrorCode;
/**
* Value Object representing dimensions (width and height)
* Can be used for images, videos, HTML elements, etc.
*/
final readonly class Dimensions
{
public function __construct(
public int $width,
public int $height
) {
if ($width <= 0) {
throw FrameworkException::create(
ErrorCode::VAL_INVALID_FORMAT,
'Width must be greater than 0'
)->withData(['width' => $width]);
}
if ($height <= 0) {
throw FrameworkException::create(
ErrorCode::VAL_INVALID_FORMAT,
'Height must be greater than 0'
)->withData(['height' => $height]);
}
}
/**
* Create from image file
*/
public static function fromImageFile(string $filepath): self
{
$imageInfo = getimagesize($filepath);
if ($imageInfo === false) {
throw FrameworkException::create(
ErrorCode::VAL_INVALID_FORMAT,
'Could not read dimensions from image file'
)->withData(['filepath' => $filepath]);
}
return new self($imageInfo[0], $imageInfo[1]);
}
/**
* Create from array [width, height]
*/
public static function fromArray(array $data): self
{
if (count($data) !== 2) {
throw FrameworkException::create(
ErrorCode::VAL_INVALID_FORMAT,
'Dimensions array must contain exactly 2 elements [width, height]'
)->withData(['data' => $data]);
}
return new self((int) $data[0], (int) $data[1]);
}
/**
* Calculate aspect ratio
*/
public function getAspectRatio(): float
{
return $this->width / $this->height;
}
/**
* Get orientation
*/
public function getOrientation(): Orientation
{
if ($this->height > $this->width) {
return Orientation::PORTRAIT;
}
if ($this->width > $this->height) {
return Orientation::LANDSCAPE;
}
return Orientation::SQUARE;
}
/**
* Check if dimensions are portrait orientation
*/
public function isPortrait(): bool
{
return $this->getOrientation() === Orientation::PORTRAIT;
}
/**
* Check if dimensions are landscape orientation
*/
public function isLandscape(): bool
{
return $this->getOrientation() === Orientation::LANDSCAPE;
}
/**
* Check if dimensions are square
*/
public function isSquare(): bool
{
return $this->getOrientation() === Orientation::SQUARE;
}
/**
* Get total area (width * height)
*/
public function getArea(): int
{
return $this->width * $this->height;
}
/**
* Scale dimensions proportionally to fit within max dimensions
*/
public function scaleToFit(int $maxWidth, int $maxHeight): self
{
$ratio = min($maxWidth / $this->width, $maxHeight / $this->height);
if ($ratio >= 1) {
return $this; // Already fits
}
return new self(
(int) round($this->width * $ratio),
(int) round($this->height * $ratio)
);
}
/**
* Scale dimensions proportionally to fill max dimensions (may crop)
*/
public function scaleToFill(int $maxWidth, int $maxHeight): self
{
$ratio = max($maxWidth / $this->width, $maxHeight / $this->height);
return new self(
(int) round($this->width * $ratio),
(int) round($this->height * $ratio)
);
}
/**
* Scale by factor
*/
public function scaleBy(float $factor): self
{
if ($factor <= 0) {
throw FrameworkException::create(
ErrorCode::VAL_INVALID_FORMAT,
'Scale factor must be greater than 0'
)->withData(['factor' => $factor]);
}
return new self(
(int) round($this->width * $factor),
(int) round($this->height * $factor)
);
}
/**
* Check if dimensions match
*/
public function equals(self $other): bool
{
return $this->width === $other->width && $this->height === $other->height;
}
/**
* Convert to array
*/
public function toArray(): array
{
return [
'width' => $this->width,
'height' => $this->height,
'aspect_ratio' => $this->getAspectRatio(),
'orientation' => $this->getOrientation()->value,
'area' => $this->getArea()
];
}
/**
* String representation (e.g., "1920x1080")
*/
public function __toString(): string
{
return "{$this->width}x{$this->height}";
}
}

View File

@@ -0,0 +1,288 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\ErrorCode;
/**
* Value Object representing a file size
* Wraps Byte object with file-specific semantics and validation
*/
final readonly class FileSize
{
public function __construct(
public readonly Byte $bytes
) {
// File size cannot be negative (already validated by Byte)
}
/**
* Create from bytes
*/
public static function fromBytes(int $bytes): self
{
return new self(Byte::fromBytes($bytes));
}
/**
* Create from file path
*/
public static function fromFile(string $filePath): self
{
if (!file_exists($filePath)) {
throw FrameworkException::create(
ErrorCode::VAL_INVALID_FORMAT,
'File does not exist'
)->withData(['file_path' => $filePath]);
}
$size = filesize($filePath);
if ($size === false) {
throw FrameworkException::create(
ErrorCode::VAL_INVALID_FORMAT,
'Could not determine file size'
)->withData(['file_path' => $filePath]);
}
return new self(Byte::fromBytes($size));
}
/**
* Create from kilobytes
*/
public static function fromKilobytes(float $kilobytes): self
{
return new self(Byte::fromKilobytes($kilobytes));
}
/**
* Create from megabytes
*/
public static function fromMegabytes(float $megabytes): self
{
return new self(Byte::fromMegabytes($megabytes));
}
/**
* Create from gigabytes
*/
public static function fromGigabytes(float $gigabytes): self
{
return new self(Byte::fromGigabytes($gigabytes));
}
/**
* Parse from human-readable string (e.g., "5MB", "1.5GB")
*/
public static function parse(string $value): self
{
return new self(Byte::parse($value));
}
/**
* Create zero file size
*/
public static function zero(): self
{
return new self(Byte::zero());
}
/**
* Get size in bytes
*/
public function toBytes(): int
{
return $this->bytes->toBytes();
}
/**
* Get size in kilobytes
*/
public function toKilobytes(int $precision = 2): float
{
return $this->bytes->toKilobytes($precision);
}
/**
* Get size in megabytes
*/
public function toMegabytes(int $precision = 2): float
{
return $this->bytes->toMegabytes($precision);
}
/**
* Get size in gigabytes
*/
public function toGigabytes(int $precision = 2): float
{
return $this->bytes->toGigabytes($precision);
}
/**
* Get human-readable representation
*/
public function toHumanReadable(int $precision = 2): string
{
return $this->bytes->toHumanReadable($precision);
}
/**
* Check if file size is empty (0 bytes)
*/
public function isEmpty(): bool
{
return $this->bytes->isEmpty();
}
/**
* Check if file size is not empty
*/
public function isNotEmpty(): bool
{
return $this->bytes->isNotEmpty();
}
/**
* Check if file size exceeds given limit
*/
public function exceedsLimit(self $maxSize): bool
{
return $this->bytes->greaterThan($maxSize->bytes);
}
/**
* Check if file size is within given limit
*/
public function isWithinLimit(self $maxSize): bool
{
return !$this->exceedsLimit($maxSize);
}
/**
* Check if file size is at least minimum size
*/
public function meetsMinimum(self $minSize): bool
{
return $this->bytes->greaterThan($minSize->bytes) || $this->bytes->equals($minSize->bytes);
}
/**
* Check if file size is below minimum
*/
public function belowMinimum(self $minSize): bool
{
return $this->bytes->lessThan($minSize->bytes);
}
/**
* Check if file size is within range
*/
public function isWithinRange(self $minSize, self $maxSize): bool
{
return $this->meetsMinimum($minSize) && $this->isWithinLimit($maxSize);
}
/**
* Add another file size
*/
public function add(self $other): self
{
return new self($this->bytes->add($other->bytes));
}
/**
* Subtract another file size
*/
public function subtract(self $other): self
{
return new self($this->bytes->subtract($other->bytes));
}
/**
* Multiply by factor
*/
public function multiply(float $factor): self
{
return new self($this->bytes->multiply($factor));
}
/**
* Divide by factor
*/
public function divide(float $divisor): self
{
return new self($this->bytes->divide($divisor));
}
/**
* Calculate percentage of total
*/
public function percentOf(self $total): Percentage
{
return $this->bytes->percentOf($total->bytes);
}
/**
* Compare equality
*/
public function equals(self $other): bool
{
return $this->bytes->equals($other->bytes);
}
/**
* Check if greater than other
*/
public function greaterThan(self $other): bool
{
return $this->bytes->greaterThan($other->bytes);
}
/**
* Check if less than other
*/
public function lessThan(self $other): bool
{
return $this->bytes->lessThan($other->bytes);
}
/**
* Check if file size indicates image file (typically > 0 bytes but reasonable for images)
*/
public function isReasonableImageSize(): bool
{
// Between 1 byte and 50MB is reasonable for images
$minSize = self::fromBytes(1);
$maxSize = self::fromMegabytes(50);
return $this->isWithinRange($minSize, $maxSize);
}
/**
* Check if file size indicates large file (> 10MB)
*/
public function isLargeFile(): bool
{
return $this->exceedsLimit(self::fromMegabytes(10));
}
/**
* Check if file size indicates very large file (> 100MB)
*/
public function isVeryLargeFile(): bool
{
return $this->exceedsLimit(self::fromMegabytes(100));
}
/**
* String representation
*/
public function __toString(): string
{
return $this->toHumanReadable();
}
}

View File

@@ -27,7 +27,7 @@ final readonly class Hash
return new self($hash, $algorithm);
}
public static function fromString(string $hash, HashAlgorithm $algorithm): self
public static function fromString(string $hash, HashAlgorithm $algorithm = HashAlgorithm::SHA256): self
{
return new self($hash, $algorithm);
}

View File

@@ -0,0 +1,321 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
use InvalidArgumentException;
/**
* Represents a monetary amount with currency
* Stores amounts in minor units (cents, pence, etc.) to avoid floating point issues
* Immutable value object for safe money calculations
*/
final readonly class Money
{
public function __construct(
public int $amount, // Amount in minor units (cents, pence, etc.)
public Currency $currency
) {
}
// Factory Methods
public static function zero(Currency $currency): self
{
return new self(0, $currency);
}
public static function fromMajorUnits(float $amount, Currency $currency): self
{
$minorUnits = (int) round($amount * $currency->getMinorUnit());
return new self($minorUnits, $currency);
}
public static function EUR(int $cents): self
{
return new self($cents, Currency::EUR);
}
public static function USD(int $cents): self
{
return new self($cents, Currency::USD);
}
public static function GBP(int $pence): self
{
return new self($pence, Currency::GBP);
}
public static function CHF(int $rappen): self
{
return new self($rappen, Currency::CHF);
}
// Arithmetic Operations
public function add(self $other): self
{
$this->assertSameCurrency($other);
return new self($this->amount + $other->amount, $this->currency);
}
public function subtract(self $other): self
{
$this->assertSameCurrency($other);
return new self($this->amount - $other->amount, $this->currency);
}
public function multiply(float $multiplier): self
{
$newAmount = (int) round($this->amount * $multiplier);
return new self($newAmount, $this->currency);
}
public function divide(float $divisor): self
{
if ($divisor == 0) {
throw new InvalidArgumentException('Division by zero');
}
$newAmount = (int) round($this->amount / $divisor);
return new self($newAmount, $this->currency);
}
public function negate(): self
{
return new self(-$this->amount, $this->currency);
}
public function absolute(): self
{
return new self(abs($this->amount), $this->currency);
}
// Percentage Operations
public function percentage(Percentage $percentage): self
{
$newAmount = (int) round($this->amount * ($percentage->toDecimal()));
return new self($newAmount, $this->currency);
}
public function addPercentage(Percentage $percentage): self
{
$addition = $this->percentage($percentage);
return $this->add($addition);
}
public function subtractPercentage(Percentage $percentage): self
{
$subtraction = $this->percentage($percentage);
return $this->subtract($subtraction);
}
// Allocation (splitting money)
public function allocate(int ...$ratios): array
{
if (empty($ratios)) {
throw new InvalidArgumentException('At least one ratio must be provided');
}
$total = array_sum($ratios);
if ($total === 0) {
throw new InvalidArgumentException('Total ratio must be greater than zero');
}
$remainder = $this->amount;
$results = [];
foreach ($ratios as $ratio) {
$share = (int) floor($this->amount * $ratio / $total);
$results[] = new self($share, $this->currency);
$remainder -= $share;
}
// Add remainder to first allocation
if ($remainder > 0) {
$results[0] = new self($results[0]->amount + $remainder, $this->currency);
}
return $results;
}
public function split(int $parts): array
{
if ($parts <= 0) {
throw new InvalidArgumentException('Parts must be greater than zero');
}
$ratios = array_fill(0, $parts, 1);
return $this->allocate(...$ratios);
}
// Comparison
public function equals(self $other): bool
{
return $this->amount === $other->amount
&& $this->currency->equals($other->currency);
}
public function isGreaterThan(self $other): bool
{
$this->assertSameCurrency($other);
return $this->amount > $other->amount;
}
public function isGreaterThanOrEqual(self $other): bool
{
$this->assertSameCurrency($other);
return $this->amount >= $other->amount;
}
public function isLessThan(self $other): bool
{
$this->assertSameCurrency($other);
return $this->amount < $other->amount;
}
public function isLessThanOrEqual(self $other): bool
{
$this->assertSameCurrency($other);
return $this->amount <= $other->amount;
}
public function isZero(): bool
{
return $this->amount === 0;
}
public function isPositive(): bool
{
return $this->amount > 0;
}
public function isNegative(): bool
{
return $this->amount < 0;
}
// Conversion
public function toMajorUnits(): float
{
return $this->amount / $this->currency->getMinorUnit();
}
// Formatting
public function format(): string
{
return $this->currency->formatAmount($this->amount);
}
public function formatWithCode(): string
{
$decimals = $this->currency->getDecimals();
$amount = $this->toMajorUnits();
$formatted = number_format($amount, $decimals, '.', ',');
return $formatted . ' ' . $this->currency->value;
}
// Serialization
public function toString(): string
{
return $this->formatWithCode();
}
public function __toString(): string
{
return $this->format();
}
public function toArray(): array
{
return [
'amount' => $this->amount,
'currency' => $this->currency->value,
'formatted' => $this->format(),
];
}
public static function fromArray(array $data): self
{
$currency = Currency::from($data['currency']);
return new self($data['amount'], $currency);
}
// Helper Methods
private function assertSameCurrency(self $other): void
{
if ($this->currency->isNot($other->currency)) {
throw new InvalidArgumentException(
"Cannot perform operation with different currencies: {$this->currency->value} and {$other->currency->value}"
);
}
}
// Static helpers with variadic parameters
public static function sum(self ...$monies): ?self
{
if (empty($monies)) {
return null;
}
$first = array_shift($monies);
return array_reduce($monies, fn (self $carry, self $money) => $carry->add($money), $first);
}
public static function average(self ...$monies): ?self
{
if (empty($monies)) {
return null;
}
$sum = self::sum(...$monies);
return $sum?->divide(count($monies));
}
public static function min(self ...$monies): ?self
{
if (empty($monies)) {
return null;
}
$min = $monies[0];
foreach ($monies as $money) {
if ($money->isLessThan($min)) {
$min = $money;
}
}
return $min;
}
public static function max(self ...$monies): ?self
{
if (empty($monies)) {
return null;
}
$max = $monies[0];
foreach ($monies as $money) {
if ($money->isGreaterThan($max)) {
$max = $money;
}
}
return $max;
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
/**
* Enum representing orientation of dimensions
*/
enum Orientation: string
{
case PORTRAIT = 'portrait';
case LANDSCAPE = 'landscape';
case SQUARE = 'square';
/**
* Get human-readable label
*/
public function getLabel(): string
{
return match($this) {
self::PORTRAIT => 'Portrait',
self::LANDSCAPE => 'Landscape',
self::SQUARE => 'Square'
};
}
/**
* Check if orientation is wider than tall
*/
public function isWide(): bool
{
return $this === self::LANDSCAPE;
}
/**
* Check if orientation is taller than wide
*/
public function isTall(): bool
{
return $this === self::PORTRAIT;
}
/**
* Check if orientation is equal width and height
*/
public function isEqual(): bool
{
return $this === self::SQUARE;
}
}

View File

@@ -84,6 +84,16 @@ final readonly class Percentage
return $this->value < $other->value;
}
public function greaterThanOrEqual(Percentage $other): bool
{
return $this->value >= $other->value;
}
public function lessThanOrEqual(Percentage $other): bool
{
return $this->value <= $other->value;
}
// Threshold checks (useful for monitoring)
public function isAbove(Percentage $threshold): bool
{

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
use InvalidArgumentException;
/**
* Represents a postal/ZIP code with country-specific validation
* Immutable value object for address handling
*/
final readonly class PostalCode
{
public function __construct(
public string $code,
public CountryCode $country
) {
$this->validate();
}
private function validate(): void
{
$pattern = $this->getPatternForCountry();
if ($pattern !== null && ! preg_match($pattern, $this->code)) {
throw new InvalidArgumentException(
"Invalid postal code '{$this->code}' for country {$this->country->getAlpha2()}"
);
}
}
private function getPatternForCountry(): ?string
{
return match($this->country->value) {
'DE' => '/^[0-9]{5}$/', // Germany: 12345
'AT' => '/^[0-9]{4}$/', // Austria: 1234
'CH' => '/^[0-9]{4}$/', // Switzerland: 1234
'US' => '/^[0-9]{5}(-[0-9]{4})?$/', // USA: 12345 or 12345-6789
'GB' => '/^[A-Z]{1,2}[0-9]{1,2}[A-Z]?\s?[0-9][A-Z]{2}$/i', // UK: SW1A 1AA
'FR' => '/^[0-9]{5}$/', // France: 75001
'IT' => '/^[0-9]{5}$/', // Italy: 00100
'ES' => '/^[0-9]{5}$/', // Spain: 28001
'NL' => '/^[0-9]{4}\s?[A-Z]{2}$/i', // Netherlands: 1234 AB
'BE' => '/^[0-9]{4}$/', // Belgium: 1000
'PL' => '/^[0-9]{2}-[0-9]{3}$/', // Poland: 00-001
'CA' => '/^[A-Z][0-9][A-Z]\s?[0-9][A-Z][0-9]$/i', // Canada: K1A 0B1
default => null // No validation for other countries
};
}
public function format(): string
{
return match($this->country->value) {
'US' => $this->formatUS(),
'GB' => strtoupper($this->code),
'NL' => $this->formatNL(),
'CA' => $this->formatCA(),
default => $this->code
};
}
private function formatUS(): string
{
if (strlen($this->code) === 9 && ! str_contains($this->code, '-')) {
return substr($this->code, 0, 5) . '-' . substr($this->code, 5);
}
return $this->code;
}
private function formatNL(): string
{
$clean = str_replace(' ', '', $this->code);
if (strlen($clean) === 6) {
return substr($clean, 0, 4) . ' ' . strtoupper(substr($clean, 4));
}
return $this->code;
}
private function formatCA(): string
{
$clean = str_replace(' ', '', strtoupper($this->code));
if (strlen($clean) === 6) {
return substr($clean, 0, 3) . ' ' . substr($clean, 3);
}
return strtoupper($this->code);
}
public function equals(self $other): bool
{
return $this->code === $other->code
&& $this->country->value === $other->country->value;
}
public function toString(): string
{
return $this->format();
}
public function __toString(): string
{
return $this->format();
}
public function toArray(): array
{
return [
'code' => $this->code,
'country' => $this->country->value,
'formatted' => $this->format(),
];
}
public static function fromArray(array $data): self
{
return new self(
$data['code'],
CountryCode::fromString($data['country'])
);
}
}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
use InvalidArgumentException;
/**
* Represents a street address with number and name
* Immutable value object for address handling
*/
final readonly class Street
{
public function __construct(
public string $name,
public string $number,
public ?string $addition = null
) {
if (trim($name) === '') {
throw new InvalidArgumentException('Street name cannot be empty');
}
if (trim($number) === '') {
throw new InvalidArgumentException('Street number cannot be empty');
}
}
public static function parse(string $streetAddress): self
{
// Try to parse common formats
$patterns = [
// "123 Main Street" or "123A Main Street"
'/^(\d+[A-Za-z]*)\s+(.+)$/',
// "Main Street 123" or "Main Street 123A"
'/^(.+)\s+(\d+[A-Za-z]*)$/',
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, trim($streetAddress), $matches)) {
if (ctype_digit($matches[1][0])) {
// Number comes first
return new self($matches[2], $matches[1]);
} else {
// Name comes first
return new self($matches[1], $matches[2]);
}
}
}
// If parsing fails, treat entire string as name with empty number
throw new InvalidArgumentException("Cannot parse street address: {$streetAddress}");
}
public static function create(string $name, string $number, ?string $addition = null): self
{
return new self($name, $number, $addition);
}
public function withAddition(string $addition): self
{
return new self($this->name, $this->number, $addition);
}
public function withoutAddition(): self
{
return new self($this->name, $this->number);
}
public function format(string $style = 'default'): string
{
return match($style) {
'name_first' => $this->formatNameFirst(),
'number_first' => $this->formatNumberFirst(),
default => $this->formatDefault()
};
}
private function formatDefault(): string
{
$formatted = trim($this->name) . ' ' . trim($this->number);
if ($this->addition !== null && trim($this->addition) !== '') {
$formatted .= ' ' . trim($this->addition);
}
return $formatted;
}
private function formatNameFirst(): string
{
return $this->formatDefault();
}
private function formatNumberFirst(): string
{
$formatted = trim($this->number) . ' ' . trim($this->name);
if ($this->addition !== null && trim($this->addition) !== '') {
$formatted .= ' ' . trim($this->addition);
}
return $formatted;
}
public function getFullNumber(): string
{
if ($this->addition !== null && trim($this->addition) !== '') {
return trim($this->number) . ' ' . trim($this->addition);
}
return trim($this->number);
}
public function hasAddition(): bool
{
return $this->addition !== null && trim($this->addition) !== '';
}
public function equals(self $other): bool
{
return $this->name === $other->name
&& $this->number === $other->number
&& $this->addition === $other->addition;
}
public function toString(): string
{
return $this->format();
}
public function __toString(): string
{
return $this->format();
}
public function toArray(): array
{
return [
'name' => $this->name,
'number' => $this->number,
'addition' => $this->addition,
'formatted' => $this->format(),
];
}
public static function fromArray(array $data): self
{
return new self(
$data['name'],
$data['number'],
$data['addition'] ?? null
);
}
}

View File

@@ -172,6 +172,22 @@ final readonly class Timestamp
return Duration::fromSeconds($diffInSeconds);
}
/**
* Add duration to this timestamp
*/
public function add(Duration $duration): self
{
return new self($this->microTimestamp + $duration->toSeconds());
}
/**
* Subtract duration from this timestamp
*/
public function subtract(Duration $duration): self
{
return new self($this->microTimestamp - $duration->toSeconds());
}
/**
* Calculate duration since this timestamp
*/