feat(Docker): Upgrade to PHP 8.5.0RC3 with native ext-uri support

BREAKING CHANGE: Requires PHP 8.5.0RC3

Changes:
- Update Docker base image from php:8.4-fpm to php:8.5.0RC3-fpm
- Enable ext-uri for native WHATWG URL parsing support
- Update composer.json PHP requirement from ^8.4 to ^8.5
- Add ext-uri as required extension in composer.json
- Move URL classes from Url.php85/ to Url/ directory (now compatible)
- Remove temporary PHP 8.4 compatibility workarounds

Benefits:
- Native URL parsing with Uri\WhatWg\Url class
- Better performance for URL operations
- Future-proof with latest PHP features
- Eliminates PHP version compatibility issues
This commit is contained in:
2025-10-27 09:31:28 +01:00
parent 799f74f00a
commit c8b47e647d
81 changed files with 6988 additions and 601 deletions

View File

@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace App\Framework\UserAgent;
use App\Framework\Core\ValueObjects\Version;
use App\Framework\UserAgent\Enums\BrowserType;
use App\Framework\UserAgent\Enums\EngineType;
use App\Framework\UserAgent\Enums\PlatformType;
use App\Framework\UserAgent\ValueObjects\DeviceCategory;
/**
* Value Object representing a parsed User-Agent with rich metadata
@@ -17,11 +19,11 @@ final readonly class ParsedUserAgent
public function __construct(
public string $raw,
public BrowserType $browser,
public string $browserVersion,
public Version $browserVersion,
public PlatformType $platform,
public string $platformVersion,
public Version $platformVersion,
public EngineType $engine,
public string $engineVersion,
public Version $engineVersion,
public bool $isMobile,
public bool $isBot,
public bool $isModern
@@ -41,11 +43,7 @@ final readonly class ParsedUserAgent
*/
public function getBrowserName(): string
{
if ($this->browserVersion === 'Unknown') {
return $this->browser->getDisplayName();
}
return $this->browser->getDisplayName() . ' ' . $this->browserVersion;
return $this->browser->getDisplayName() . ' ' . $this->browserVersion->toString();
}
/**
@@ -53,11 +51,7 @@ final readonly class ParsedUserAgent
*/
public function getPlatformName(): string
{
if ($this->platformVersion === 'Unknown') {
return $this->platform->getDisplayName();
}
return $this->platform->getDisplayName() . ' ' . $this->platformVersion;
return $this->platform->getDisplayName() . ' ' . $this->platformVersion->toString();
}
/**
@@ -65,11 +59,7 @@ final readonly class ParsedUserAgent
*/
public function getEngineName(): string
{
if ($this->engineVersion === 'Unknown') {
return $this->engine->getDisplayName();
}
return $this->engine->getDisplayName() . ' ' . $this->engineVersion;
return $this->engine->getDisplayName() . ' ' . $this->engineVersion->toString();
}
/**
@@ -104,16 +94,18 @@ final readonly class ParsedUserAgent
return match ($feature) {
// Image formats
'webp' => $this->browser->getEngine() === EngineType::BLINK ||
($this->browser === BrowserType::FIREFOX && version_compare($this->browserVersion, '65.0', '>=')),
($this->browser === BrowserType::FIREFOX &&
$this->browserVersion->isNewerThan(Version::fromString('65.0')) ||
$this->browserVersion->equals(Version::fromString('65.0'))),
'avif' => $this->browser->getEngine() === EngineType::BLINK &&
version_compare($this->browserVersion, '85.0', '>='),
($this->browserVersion->isNewerThan(Version::fromString('85.0')) ||
$this->browserVersion->equals(Version::fromString('85.0'))),
// JavaScript features
'es6', 'css-custom-properties', 'css-flexbox', 'css-grid', 'webrtc', 'websockets' => $this->isModern,
'es2017' => $this->isModern && version_compare($this->browserVersion, $this->getEs2017MinVersion(), '>='),
'es2020' => $this->isModern && version_compare($this->browserVersion, $this->getEs2020MinVersion(), '>='),
'es2017' => $this->isModern && $this->supportsEs2017(),
'es2020' => $this->isModern && $this->supportsEs2020(),
// CSS features
// Web APIs
'service-worker' => $this->isModern && $this->platform !== PlatformType::IOS,
'web-push' => $this->isModern && $this->browser !== BrowserType::SAFARI,
@@ -122,54 +114,80 @@ final readonly class ParsedUserAgent
};
}
/**
* Check if browser supports ES2017
*/
private function supportsEs2017(): bool
{
$minVersion = $this->getEs2017MinVersion();
return $this->browserVersion->isNewerThan($minVersion) ||
$this->browserVersion->equals($minVersion);
}
/**
* Check if browser supports ES2020
*/
private function supportsEs2020(): bool
{
$minVersion = $this->getEs2020MinVersion();
return $this->browserVersion->isNewerThan($minVersion) ||
$this->browserVersion->equals($minVersion);
}
/**
* Get minimum browser version for ES2017 support
*/
private function getEs2017MinVersion(): string
private function getEs2017MinVersion(): Version
{
return match ($this->browser) {
BrowserType::CHROME => '58.0',
BrowserType::FIREFOX => '52.0',
BrowserType::SAFARI => '10.1',
BrowserType::EDGE => '79.0',
BrowserType::OPERA => '45.0',
default => '999.0'
$versionString = match ($this->browser) {
BrowserType::CHROME => '58.0.0',
BrowserType::FIREFOX => '52.0.0',
BrowserType::SAFARI => '10.1.0',
BrowserType::EDGE => '79.0.0',
BrowserType::OPERA => '45.0.0',
default => '999.0.0'
};
return Version::fromString($versionString);
}
/**
* Get minimum browser version for ES2020 support
*/
private function getEs2020MinVersion(): string
private function getEs2020MinVersion(): Version
{
return match ($this->browser) {
BrowserType::CHROME => '80.0',
BrowserType::FIREFOX => '72.0',
BrowserType::SAFARI => '13.1',
BrowserType::EDGE => '80.0',
BrowserType::OPERA => '67.0',
default => '999.0'
$versionString = match ($this->browser) {
BrowserType::CHROME => '80.0.0',
BrowserType::FIREFOX => '72.0.0',
BrowserType::SAFARI => '13.1.0',
BrowserType::EDGE => '80.0.0',
BrowserType::OPERA => '67.0.0',
default => '999.0.0'
};
return Version::fromString($versionString);
}
/**
* Get device category
*/
public function getDeviceCategory(): string
public function getDeviceCategory(): DeviceCategory
{
if ($this->isBot) {
return 'bot';
return DeviceCategory::BOT;
}
if ($this->platform->isMobile()) {
return 'mobile';
return DeviceCategory::MOBILE;
}
if ($this->platform->isDesktop()) {
return 'desktop';
return DeviceCategory::DESKTOP;
}
return 'unknown';
return DeviceCategory::UNKNOWN;
}
/**
@@ -183,20 +201,20 @@ final readonly class ParsedUserAgent
'browser' => [
'type' => $this->browser->value,
'name' => $this->browser->getDisplayName(),
'version' => $this->browserVersion,
'version' => $this->browserVersion->toString(),
'fullName' => $this->getBrowserName(),
],
'platform' => [
'type' => $this->platform->value,
'name' => $this->platform->getDisplayName(),
'version' => $this->platformVersion,
'version' => $this->platformVersion->toString(),
'fullName' => $this->getPlatformName(),
'family' => $this->platform->getFamily(),
],
'engine' => [
'type' => $this->engine->value,
'name' => $this->engine->getDisplayName(),
'version' => $this->engineVersion,
'version' => $this->engineVersion->toString(),
'fullName' => $this->getEngineName(),
'developer' => $this->engine->getDeveloper(),
],
@@ -205,7 +223,7 @@ final readonly class ParsedUserAgent
'isBot' => $this->isBot,
'isModern' => $this->isModern,
],
'deviceCategory' => $this->getDeviceCategory(),
'deviceCategory' => $this->getDeviceCategory()->value,
'summary' => $this->getSummary(),
];
}

View File

@@ -5,6 +5,11 @@ declare(strict_types=1);
namespace App\Framework\UserAgent;
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Hash;
use App\Framework\Core\ValueObjects\HashAlgorithm;
use App\Framework\Core\ValueObjects\Version;
use App\Framework\UserAgent\Enums\BrowserType;
use App\Framework\UserAgent\Enums\EngineType;
use App\Framework\UserAgent\Enums\PlatformType;
@@ -20,7 +25,8 @@ final readonly class UserAgentParser
{
public function __construct(
private ?Cache $cache = null
) {}
) {
}
/**
* Parse User-Agent string into structured ParsedUserAgent object
@@ -34,8 +40,9 @@ final readonly class UserAgentParser
return $this->createUnknownUserAgent('');
}
// Check cache first
$cacheKey = 'useragent:' . md5($normalized);
// Check cache first (using framework's Hash VO with fast algorithm)
$hash = Hash::create($normalized, HashAlgorithm::fast());
$cacheKey = CacheKey::fromString('useragent:' . $hash->toString());
if ($this->cache) {
$cached = $this->cache->get($cacheKey);
if ($cached instanceof ParsedUserAgent) {
@@ -67,9 +74,9 @@ final readonly class UserAgentParser
isModern: $isModern
);
// Cache result
// Cache result for 1 hour
if ($this->cache) {
$this->cache->set($cacheKey, $parsedUserAgent, 3600); // Cache for 1 hour
$this->cache->set($cacheKey, $parsedUserAgent, Duration::fromHours(1));
}
return $parsedUserAgent;
@@ -99,16 +106,18 @@ final readonly class UserAgentParser
/**
* Parse browser version
*/
private function parseBrowserVersion(string $userAgent, BrowserType $browser): string
private function parseBrowserVersion(string $userAgent, BrowserType $browser): Version
{
// Find matching pattern for this browser
foreach (BrowserPatterns::getPatterns() as $pattern) {
if ($pattern['browser'] === $browser && preg_match($pattern['versionPattern'], $userAgent, $matches)) {
return $matches[1] ?? 'Unknown';
$versionString = $matches[1] ?? '0.0.0';
return $this->parseVersion($versionString);
}
}
return 'Unknown';
return Version::fromString('0.0.0');
}
/**
@@ -128,25 +137,27 @@ final readonly class UserAgentParser
/**
* Parse platform version
*/
private function parsePlatformVersion(string $userAgent, PlatformType $platform): string
private function parsePlatformVersion(string $userAgent, PlatformType $platform): Version
{
foreach (PlatformPatterns::getPatterns() as $pattern) {
if ($pattern['platform'] === $platform &&
! empty($pattern['versionPattern']) &&
preg_match($pattern['versionPattern'], $userAgent, $matches)) {
$version = $matches[1] ?? 'Unknown';
$version = $matches[1] ?? '0.0.0';
// Format version based on platform
return match ($platform) {
$formattedVersion = match ($platform) {
PlatformType::WINDOWS => PlatformPatterns::formatWindowsVersion($version),
PlatformType::MACOS, PlatformType::IOS => PlatformPatterns::formatAppleVersion($version),
default => $version
};
return $this->parseVersion($formattedVersion);
}
}
return 'Unknown';
return Version::fromString('0.0.0');
}
/**
@@ -170,30 +181,32 @@ final readonly class UserAgentParser
/**
* Parse engine version
*/
private function parseEngineVersion(string $userAgent, EngineType $engine): string
private function parseEngineVersion(string $userAgent, EngineType $engine): Version
{
foreach (EnginePatterns::getPatterns() as $pattern) {
if ($pattern['engine'] === $engine && preg_match($pattern['versionPattern'], $userAgent, $matches)) {
$version = $matches[1] ?? 'Unknown';
$version = $matches[1] ?? '0.0.0';
// Special formatting for Gecko
if ($engine === EngineType::GECKO) {
return EnginePatterns::formatGeckoVersion($version);
$formattedVersion = EnginePatterns::formatGeckoVersion($version);
return $this->parseVersion($formattedVersion);
}
return $version;
return $this->parseVersion($version);
}
}
return 'Unknown';
return Version::fromString('0.0.0');
}
/**
* Determine if browser is considered modern
*/
private function determineModernBrowser(BrowserType $browser, string $version, bool $isBot): bool
private function determineModernBrowser(BrowserType $browser, Version $version, bool $isBot): bool
{
if ($isBot || $version === 'Unknown') {
if ($isBot) {
return false;
}
@@ -201,9 +214,9 @@ final readonly class UserAgentParser
return false;
}
$threshold = $browser->getModernVersionThreshold();
$threshold = Version::fromString($browser->getModernVersionThreshold());
return version_compare($version, $threshold, '>=');
return $version->isNewerThan($threshold) || $version->equals($threshold);
}
/**
@@ -214,17 +227,48 @@ final readonly class UserAgentParser
return new ParsedUserAgent(
raw: $raw,
browser: BrowserType::UNKNOWN,
browserVersion: 'Unknown',
browserVersion: Version::fromString('0.0.0'),
platform: PlatformType::UNKNOWN,
platformVersion: 'Unknown',
platformVersion: Version::fromString('0.0.0'),
engine: EngineType::UNKNOWN,
engineVersion: 'Unknown',
engineVersion: Version::fromString('0.0.0'),
isMobile: false,
isBot: false,
isModern: false
);
}
/**
* Parse version string into Version Value Object
* Handles various version formats from User-Agent strings
*/
private function parseVersion(string $versionString): Version
{
// Normalize version string
$normalized = trim($versionString);
if ($normalized === '' || $normalized === 'Unknown') {
return Version::fromString('0.0.0');
}
// Try to parse as semver
try {
return Version::fromString($normalized);
} catch (\InvalidArgumentException $e) {
// If parsing fails, try to extract major.minor.patch from string
if (preg_match('/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/', $normalized, $matches)) {
$major = (int) $matches[1];
$minor = isset($matches[2]) ? (int) $matches[2] : 0;
$patch = isset($matches[3]) ? (int) $matches[3] : 0;
return Version::fromComponents($major, $minor, $patch);
}
// Fallback to 0.0.0 if we can't parse
return Version::fromString('0.0.0');
}
}
/**
* Clear parser cache
*/

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Framework\UserAgent\ValueObjects;
/**
* Value Object representing device category
*/
enum DeviceCategory: string
{
case BOT = 'bot';
case MOBILE = 'mobile';
case DESKTOP = 'desktop';
case TABLET = 'tablet';
case UNKNOWN = 'unknown';
/**
* Get human-readable display name
*/
public function getDisplayName(): string
{
return match ($this) {
self::BOT => 'Bot',
self::MOBILE => 'Mobile Device',
self::DESKTOP => 'Desktop Computer',
self::TABLET => 'Tablet',
self::UNKNOWN => 'Unknown Device',
};
}
/**
* Check if device is mobile (includes tablets)
*/
public function isMobile(): bool
{
return match ($this) {
self::MOBILE, self::TABLET => true,
default => false,
};
}
/**
* Check if device is desktop
*/
public function isDesktop(): bool
{
return $this === self::DESKTOP;
}
/**
* Check if device is a bot
*/
public function isBot(): bool
{
return $this === self::BOT;
}
}