From 3a06f7143caf5d3cfdea52efcf38265c61b03959 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:32:16 +0400 Subject: [PATCH 01/39] Enforce PHPStan level 7 compliance for App package --- src/App/Adapters/ConsoleAppAdapter.php | 12 ++++++------ src/App/Adapters/WebAppAdapter.php | 2 +- src/App/App.php | 2 +- src/App/Exceptions/StopExecutionException.php | 2 +- src/App/Helpers/misc.php | 8 ++++---- src/App/Traits/AppTrait.php | 4 +++- src/App/Traits/ConsoleAppTrait.php | 8 ++++++-- 7 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/App/Adapters/ConsoleAppAdapter.php b/src/App/Adapters/ConsoleAppAdapter.php index e2b86d87..4d2875f5 100644 --- a/src/App/Adapters/ConsoleAppAdapter.php +++ b/src/App/Adapters/ConsoleAppAdapter.php @@ -44,7 +44,7 @@ class ConsoleAppAdapter extends AppAdapter protected ConsoleOutput $output; - protected ?Application $application = null; + protected Application $application; public function __construct() { @@ -59,6 +59,11 @@ public function __construct() $this->loadEnvironment(); $this->loadAppConfig(); } + + $this->application = $this->createApplication( + config()->get('app.name', 'UNKNOWN'), + config()->get('app.version', 'UNKNOWN') + ); } /** @@ -72,11 +77,6 @@ public function __construct() public function start(): ?int { try { - $this->application = $this->createApplication( - config()->get('app.name', 'UNKNOWN'), - config()->get('app.version', 'UNKNOWN') - ); - $this->loadLanguage(); $this->registerCoreCommands(); diff --git a/src/App/Adapters/WebAppAdapter.php b/src/App/Adapters/WebAppAdapter.php index 39681c5d..9dc0cf61 100644 --- a/src/App/Adapters/WebAppAdapter.php +++ b/src/App/Adapters/WebAppAdapter.php @@ -146,7 +146,7 @@ public function start(): ?int $viewCache = $this->setupViewCache(); - if ($viewCache->serveCachedView(route_uri(), $this->response)) { + if ($viewCache->serveCachedView(route_uri() ?? '', $this->response)) { stop(); } diff --git a/src/App/App.php b/src/App/App.php index 467d101f..c7dc5b56 100644 --- a/src/App/App.php +++ b/src/App/App.php @@ -42,7 +42,7 @@ public static function setBaseDir(string $baseDir): void public static function getBaseDir(): string { - return self::$baseDir; + return self::$baseDir ?? ''; } public function getAdapter(): AppInterface diff --git a/src/App/Exceptions/StopExecutionException.php b/src/App/Exceptions/StopExecutionException.php index 8512dcc5..f8c5e06d 100644 --- a/src/App/Exceptions/StopExecutionException.php +++ b/src/App/Exceptions/StopExecutionException.php @@ -28,7 +28,7 @@ public static function executionTerminated(?int $code): self { return new self( ExceptionMessages::EXECUTION_TERMINATED, - $code + $code ?? 0 ); } } diff --git a/src/App/Helpers/misc.php b/src/App/Helpers/misc.php index f51371ee..f4a333bc 100644 --- a/src/App/Helpers/misc.php +++ b/src/App/Helpers/misc.php @@ -24,9 +24,9 @@ function _message(string $subject, $params): string if (is_array($params)) { return preg_replace_callback('/{%\d+}/', function () use (&$params) { return array_shift($params); - }, $subject); + }, $subject) ?? $subject; } else { - return preg_replace('/{%\d+}/', $params, $subject); + return preg_replace('/{%\d+}/', $params, $subject) ?? $subject; } } @@ -62,8 +62,8 @@ function random_number(int $length = 10): int function slugify(string $text): string { $text = trim($text, ' '); - $text = preg_replace('/[^\p{L}\p{N}]/u', ' ', $text); - $text = preg_replace('/\s+/', '-', $text); + $text = preg_replace('/[^\p{L}\p{N}]/u', ' ', $text) ?? $text; + $text = preg_replace('/\s+/', '-', $text) ?? $text; $text = trim($text, '-'); $text = mb_strtolower($text); diff --git a/src/App/Traits/AppTrait.php b/src/App/Traits/AppTrait.php index 262ce94f..620cb44c 100644 --- a/src/App/Traits/AppTrait.php +++ b/src/App/Traits/AppTrait.php @@ -76,7 +76,9 @@ protected function loadComponentHelperFunctions(): void $srcDir = dirname(__DIR__, 2); - foreach (glob($srcDir . DS . '*', GLOB_ONLYDIR) as $componentDir) { + $componentDirs = glob($srcDir . DS . '*', GLOB_ONLYDIR); + + foreach (is_array($componentDirs) ? $componentDirs : [] as $componentDir) { $helperPath = $componentDir . DS . 'Helpers'; if (is_dir($helperPath)) { $loader->loadDir($helperPath); diff --git a/src/App/Traits/ConsoleAppTrait.php b/src/App/Traits/ConsoleAppTrait.php index 04f60052..85c2be9b 100644 --- a/src/App/Traits/ConsoleAppTrait.php +++ b/src/App/Traits/ConsoleAppTrait.php @@ -17,6 +17,7 @@ namespace Quantum\App\Traits; use Quantum\Environment\Exceptions\EnvException; +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Application; use Quantum\App\Exceptions\BaseException; use Quantum\Di\Exceptions\DiException; @@ -80,7 +81,10 @@ private function registerCommands(string $directory, string $namespace): void $commands = CommandDiscovery::discover($directory, $namespace); foreach ($commands as $command) { - $this->application->add(new $command['class']()); + $instance = new $command['class'](); + if ($instance instanceof Command) { + $this->application->add($instance); + } } } @@ -91,7 +95,7 @@ private function validateCommand(): void { $commandName = $this->input->getFirstArgument(); - if (!$this->application->has($commandName)) { + if ($commandName === null || !$this->application->has($commandName)) { throw new Exception("Command `$commandName` is not defined"); } } From 913adda588049ef47e0edda9dba215a2ba3be2d0 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:36:35 +0400 Subject: [PATCH 02/39] Enforce PHPStan level 7 compliance for Archive package --- src/Archive/Adapters/PharAdapter.php | 6 +++++- src/Archive/Adapters/ZipAdapter.php | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Archive/Adapters/PharAdapter.php b/src/Archive/Adapters/PharAdapter.php index 163c7e3e..c9c9a882 100644 --- a/src/Archive/Adapters/PharAdapter.php +++ b/src/Archive/Adapters/PharAdapter.php @@ -107,7 +107,9 @@ public function addFile(string $filePath, ?string $entryName = null): bool } try { - $this->archive->addFile($filePath, $entryName); + $entryName !== null + ? $this->archive->addFile($filePath, $entryName) + : $this->archive->addFile($filePath); return true; } catch (Exception $e) { return false; @@ -213,6 +215,8 @@ public function deleteMultipleFiles(array $fileNames): bool /** * @throws ArchiveException + * @phpstan-assert Phar $this->archive + * @phpstan-assert string $this->archiveName */ private function ensureArchiveOpen(): void { diff --git a/src/Archive/Adapters/ZipAdapter.php b/src/Archive/Adapters/ZipAdapter.php index c7b4b19a..3129adbf 100644 --- a/src/Archive/Adapters/ZipAdapter.php +++ b/src/Archive/Adapters/ZipAdapter.php @@ -94,7 +94,9 @@ public function addFile(string $filePath, ?string $entryName = null): bool throw ArchiveException::fileNotFound($filePath); } - $result = $this->archive->addFile($filePath, $entryName); + $result = $entryName !== null + ? $this->archive->addFile($filePath, $entryName) + : $this->archive->addFile($filePath); $this->requiresReopen = true; return $result; @@ -195,6 +197,7 @@ public function deleteMultipleFiles(array $fileNames): bool /** * @throws ArchiveException + * @phpstan-assert ZipArchive $this->archive */ private function ensureArchiveOpen(): void { From 8082b4eab3274d218eac978a8889963e72be4eee Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:57:02 +0400 Subject: [PATCH 03/39] Enforce PHPStan level 7 compliance for Asset package and remove redundant nullable types --- src/Asset/Asset.php | 10 +++++----- src/Asset/AssetManager.php | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Asset/Asset.php b/src/Asset/Asset.php index 6ed96e9f..64ab140a 100644 --- a/src/Asset/Asset.php +++ b/src/Asset/Asset.php @@ -43,7 +43,7 @@ class Asset */ private ?array $attributes; - private ?int $position; + private int $position; /** * Asset templates @@ -58,7 +58,7 @@ class Asset * Asset constructor * @param array|null $attributes */ - public function __construct(int $type, string $path, ?string $name = null, ?int $position = -1, ?array $attributes = []) + public function __construct(int $type, string $path, ?string $name = null, int $position = -1, ?array $attributes = []) { $this->type = $type; $this->path = $path; @@ -76,7 +76,7 @@ public function getType(): int } /** - * Gets asset path + * Gets an asset path */ public function getPath(): string { @@ -94,7 +94,7 @@ public function getName(): ?string /** * Gets asset position */ - public function getPosition(): ?int + public function getPosition(): int { return $this->position; } @@ -127,7 +127,7 @@ public function tag(): string { return _message( $this->templates[$this->type], - [$this->url(), implode(' ', $this->attributes)] + [$this->url(), implode(' ', $this->attributes ?? [])] ) . PHP_EOL; } } diff --git a/src/Asset/AssetManager.php b/src/Asset/AssetManager.php index 03f31811..1c81558e 100644 --- a/src/Asset/AssetManager.php +++ b/src/Asset/AssetManager.php @@ -169,7 +169,7 @@ private function setPriorityAssets(): void $position = $asset->getPosition(); $type = $asset->getType(); - if ($position != -1) { + if ($position !== -1) { if (isset($this->published[$type][$position])) { throw AssetException::positionInUse($position, $asset->getPath()); } @@ -185,7 +185,7 @@ private function setPriorityAssets(): void private function setRegularAssets(): void { foreach ($this->store as $asset) { - if ($asset->getPosition() == -1) { + if ($asset->getPosition() === -1) { $this->setPosition($asset, 0); } } From 5159f7df1f346703938cfc81704f58d6c4649599 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 27 Mar 2026 20:29:59 +0400 Subject: [PATCH 04/39] Refactor Auth package with strict types and clean Factory patterns for PHPStan level 7 --- src/Auth/Adapters/JwtAuthAdapter.php | 9 ++++--- src/Auth/Factories/AuthFactory.php | 26 ++++++++++++------- src/Auth/Traits/AuthTrait.php | 39 +++++++++------------------- 3 files changed, 35 insertions(+), 39 deletions(-) diff --git a/src/Auth/Adapters/JwtAuthAdapter.php b/src/Auth/Adapters/JwtAuthAdapter.php index 853b9dc5..daed3a4b 100644 --- a/src/Auth/Adapters/JwtAuthAdapter.php +++ b/src/Auth/Adapters/JwtAuthAdapter.php @@ -38,10 +38,12 @@ class JwtAuthAdapter implements AuthenticatableInterface { use AuthTrait; + protected JwtToken $jwt; + /** * @throws AuthException */ - public function __construct(AuthServiceInterface $authService, Mailer $mailer, Hasher $hasher, ?JwtToken $jwt = null) + public function __construct(AuthServiceInterface $authService, Mailer $mailer, Hasher $hasher, JwtToken $jwt) { $this->authService = $authService; $this->mailer = $mailer; @@ -138,11 +140,12 @@ public function verifyOtp(int $otp, string $otpToken): array /** * Get Updated Tokens - * @throws JwtException * @return array + * @throws JwtException */ protected function getUpdatedTokens(User $user): array { + return [ $this->keyFields[AuthKeys::REFRESH_TOKEN] => $this->generateToken(), $this->keyFields[AuthKeys::ACCESS_TOKEN] => base64_encode($this->jwt->setData($this->getVisibleFields($user))->compose()), @@ -151,8 +154,8 @@ protected function getUpdatedTokens(User $user): array /** * Set Updated Tokens - * @throws JwtException * @return array + * @throws JwtException */ protected function setUpdatedTokens(User $user): array { diff --git a/src/Auth/Factories/AuthFactory.php b/src/Auth/Factories/AuthFactory.php index c2e486cc..f353abb8 100644 --- a/src/Auth/Factories/AuthFactory.php +++ b/src/Auth/Factories/AuthFactory.php @@ -16,14 +16,15 @@ namespace Quantum\Auth\Factories; +use Quantum\Auth\Contracts\AuthenticatableInterface; use Quantum\Auth\Contracts\AuthServiceInterface; use Quantum\Service\Exceptions\ServiceException; use Quantum\Config\Exceptions\ConfigException; use Quantum\Service\Factories\ServiceFactory; use Quantum\Auth\Adapters\SessionAuthAdapter; use Quantum\Auth\Exceptions\AuthException; -use Quantum\Auth\Adapters\JwtAuthAdapter; use Quantum\App\Exceptions\BaseException; +use Quantum\Auth\Adapters\JwtAuthAdapter; use Quantum\Di\Exceptions\DiException; use Quantum\Auth\Enums\AuthType; use Quantum\Service\QtService; @@ -87,12 +88,17 @@ public static function get(?string $adapter = null): Auth */ private static function createInstance(string $adapterClass, string $adapter): Auth { - return new Auth(new $adapterClass( - self::createAuthService($adapter), - mailer(), - new Hasher(), - self::createJwtInstance($adapter) - )); + $authService = self::createAuthService($adapter); + + $adapterInstance = $adapter === AuthType::JWT + ? new $adapterClass($authService, mailer(), new Hasher(), self::createJwtInstance()) + : new $adapterClass($authService, mailer(), new Hasher()); + + if (!$adapterInstance instanceof AuthenticatableInterface) { + throw AuthException::adapterNotSupported($adapter); + } + + return new Auth($adapterInstance); } /** @@ -127,8 +133,10 @@ private static function createAuthService(string $adapter): AuthServiceInterface return $authService; } - private static function createJwtInstance(string $adapter): ?JwtToken + private static function createJwtInstance(): JwtToken { - return $adapter === AuthType::JWT ? (new JwtToken())->setLeeway(1)->setClaims((array) config()->get('auth.claims')) : null; + return (new JwtToken()) + ->setLeeway(1) + ->setClaims((array) config()->get('auth.claims')); } } diff --git a/src/Auth/Traits/AuthTrait.php b/src/Auth/Traits/AuthTrait.php index ccec97da..2e5b6297 100644 --- a/src/Auth/Traits/AuthTrait.php +++ b/src/Auth/Traits/AuthTrait.php @@ -39,40 +39,23 @@ */ trait AuthTrait { - /** - * @var Mailer - */ - protected $mailer; - - /** - * @var Hasher - */ - protected $hasher; + protected Mailer $mailer; - /** - * @var JwtToken - */ - protected $jwt; + protected Hasher $hasher; - /** - * @var AuthServiceInterface - */ - protected $authService; + protected AuthServiceInterface $authService; - /** - * @var int - */ - protected $otpLength = 6; + protected int $otpLength = 6; /** * @var array */ - protected $keyFields = []; + protected array $keyFields = []; /** * @var array */ - protected $visibleFields = []; + protected array $visibleFields = []; /** * @inheritDoc @@ -214,7 +197,7 @@ protected function getUser(string $username, string $password): User throw AuthException::incorrectCredentials(); } - if (!$this->hasher->check($password, $user->getFieldValue($this->keyFields[AuthKeys::PASSWORD]))) { + if (!$this->hasher->check($password, $user->getFieldValue($this->keyFields[AuthKeys::PASSWORD]) ?? '')) { throw AuthException::incorrectCredentials(); } @@ -275,7 +258,9 @@ protected function verifyAndUpdateOtp(int $otp, string $otpToken): User throw AuthException::incorrectVerificationCode(); } - if (new DateTime() >= new DateTime($user->getFieldValue($this->keyFields[AuthKeys::OTP_EXPIRY]))) { + $otpExpiry = $user->getFieldValue($this->keyFields[AuthKeys::OTP_EXPIRY]); + + if (!$otpExpiry || new DateTime() >= new DateTime($otpExpiry)) { throw AuthException::verificationCodeExpired(); } @@ -324,7 +309,7 @@ protected function isActivated(User $user): bool */ protected function generateToken(?string $val = null): string { - return base64_encode($this->hasher->hash($val ?: config()->get('app.key'))); + return base64_encode($this->hasher->hash($val ?: config()->get('app.key')) ?? ''); } /** @@ -339,7 +324,7 @@ protected function sendMail(User $user, array $body): void $appName = config()->get('app.name') ?: ''; $this->mailer->setFrom($appEmail, $appName) - ->setAddress($user->getFieldValue($this->keyFields[AuthKeys::USERNAME]), $fullName) + ->setAddress($user->getFieldValue($this->keyFields[AuthKeys::USERNAME]) ?? '', $fullName) ->setBody($body) ->send(); } From 2310de5bc3d9d912bc135997313b6dd91df11609 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 27 Mar 2026 20:31:08 +0400 Subject: [PATCH 05/39] Code cleanup and cs fix for Auth package --- src/Auth/Factories/AuthFactory.php | 2 +- src/Auth/Traits/AuthTrait.php | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Auth/Factories/AuthFactory.php b/src/Auth/Factories/AuthFactory.php index f353abb8..edad0b1e 100644 --- a/src/Auth/Factories/AuthFactory.php +++ b/src/Auth/Factories/AuthFactory.php @@ -89,7 +89,7 @@ public static function get(?string $adapter = null): Auth private static function createInstance(string $adapterClass, string $adapter): Auth { $authService = self::createAuthService($adapter); - + $adapterInstance = $adapter === AuthType::JWT ? new $adapterClass($authService, mailer(), new Hasher(), self::createJwtInstance()) : new $adapterClass($authService, mailer(), new Hasher()); diff --git a/src/Auth/Traits/AuthTrait.php b/src/Auth/Traits/AuthTrait.php index 2e5b6297..5a4c7505 100644 --- a/src/Auth/Traits/AuthTrait.php +++ b/src/Auth/Traits/AuthTrait.php @@ -25,7 +25,6 @@ use Quantum\Auth\Enums\AuthKeys; use Quantum\Mailer\Mailer; use Quantum\Hasher\Hasher; -use Quantum\Jwt\JwtToken; use ReflectionException; use Quantum\Auth\User; use ReflectionClass; From 13e12230e69492899925fc24bcbcefd15a6ca693 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:34:17 +0400 Subject: [PATCH 06/39] Refactor Cache package for PHPStan level 7: extract CacheTrait, add per-key TTL support to FileAdapter via touch(), fix error messages and return types --- src/Cache/Adapters/DatabaseAdapter.php | 51 +++++++------- src/Cache/Adapters/FileAdapter.php | 30 ++++----- src/Cache/Adapters/MemcachedAdapter.php | 35 +++++----- src/Cache/Adapters/RedisAdapter.php | 46 +++++++------ src/Cache/Factories/CacheFactory.php | 12 +++- src/Cache/Traits/CacheTrait.php | 66 +++++++++++++++++++ .../Cache/Adapters/DatabaseAdapterTest.php | 39 +++++++++++ tests/Unit/Cache/Adapters/FileAdapterTest.php | 39 +++++++++++ .../Cache/Adapters/MemcachedAdapterTest.php | 28 ++++++++ .../Unit/Cache/Adapters/RedisAdapterTest.php | 28 ++++++++ 10 files changed, 289 insertions(+), 85 deletions(-) create mode 100644 src/Cache/Traits/CacheTrait.php diff --git a/src/Cache/Adapters/DatabaseAdapter.php b/src/Cache/Adapters/DatabaseAdapter.php index 2fc24830..555fb002 100644 --- a/src/Cache/Adapters/DatabaseAdapter.php +++ b/src/Cache/Adapters/DatabaseAdapter.php @@ -17,7 +17,9 @@ namespace Quantum\Cache\Adapters; use Quantum\Cache\Enums\ExceptionMessages; +use Quantum\App\Exceptions\BaseException; use Quantum\Model\Factories\ModelFactory; +use Quantum\Cache\Traits\CacheTrait; use Psr\SimpleCache\CacheInterface; use InvalidArgumentException; use Quantum\Model\DbModel; @@ -29,15 +31,7 @@ */ class DatabaseAdapter implements CacheInterface { - /** - * @var int - */ - private $ttl; - - /** - * @var string - */ - private $prefix; + use CacheTrait; private DbModel $cacheModel; @@ -53,12 +47,17 @@ public function __construct(array $params) /** * @inheritDoc + * @throws BaseException */ public function get($key, $default = null) { if ($this->has($key)) { $cacheItem = $this->cacheModel->findOneBy('key', $this->keyHash($key)); + if ($cacheItem === null) { + return $default; + } + try { return unserialize($cacheItem->prop('value')); } catch (Exception $e) { @@ -72,14 +71,17 @@ public function get($key, $default = null) /** * @inheritDoc - * @throws InvalidArgumentException * @param iterable $keys * @return iterable + * @throws InvalidArgumentException|BaseException */ public function getMultiple($keys, $default = null) { if (!is_array($keys)) { - throw new InvalidArgumentException(_message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$keys'), E_WARNING); + throw new InvalidArgumentException( + _message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$keys'), + E_WARNING + ); } $result = []; @@ -98,11 +100,11 @@ public function has($key): bool { $cacheItem = $this->cacheModel->findOneBy('key', $this->keyHash($key)); - if (empty($cacheItem->asArray())) { + if ($cacheItem === null || empty($cacheItem->asArray())) { return false; } - if (time() - $cacheItem->prop('ttl') > $this->ttl) { + if (time() >= $cacheItem->prop('ttl')) { $this->delete($key); return false; } @@ -117,13 +119,13 @@ public function set($key, $value, $ttl = null): bool { $cacheItem = $this->cacheModel->findOneBy('key', $this->keyHash($key)); - if (empty($cacheItem->asArray())) { + if ($cacheItem === null || empty($cacheItem->asArray())) { $cacheItem = $this->cacheModel->create(); } $cacheItem->prop('key', $this->keyHash($key)); $cacheItem->prop('value', serialize($value)); - $cacheItem->prop('ttl', time()); + $cacheItem->prop('ttl', time() + $this->normalizeTtl($ttl)); return $cacheItem->save(); } @@ -136,7 +138,10 @@ public function set($key, $value, $ttl = null): bool public function setMultiple($values, $ttl = null): bool { if (!is_array($values)) { - throw new InvalidArgumentException(_message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$values'), E_WARNING); + throw new InvalidArgumentException( + _message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$values'), + E_WARNING + ); } $results = []; @@ -155,7 +160,7 @@ public function delete($key): bool { $cacheItem = $this->cacheModel->findOneBy('key', $this->keyHash($key)); - if (!empty($cacheItem->asArray())) { + if ($cacheItem !== null && !empty($cacheItem->asArray())) { return $cacheItem->delete(); } @@ -170,7 +175,10 @@ public function delete($key): bool public function deleteMultiple($keys): bool { if (!is_array($keys)) { - throw new InvalidArgumentException(_message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$keys'), E_WARNING); + throw new InvalidArgumentException( + _message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$keys'), + E_WARNING + ); } $results = []; @@ -190,11 +198,4 @@ public function clear(): bool return $this->cacheModel->deleteMany(); } - /** - * Gets the hashed key - */ - private function keyHash(string $key): string - { - return sha1($this->prefix . $key); - } } diff --git a/src/Cache/Adapters/FileAdapter.php b/src/Cache/Adapters/FileAdapter.php index 3a43d6d1..41939908 100644 --- a/src/Cache/Adapters/FileAdapter.php +++ b/src/Cache/Adapters/FileAdapter.php @@ -22,6 +22,7 @@ use Quantum\App\Exceptions\BaseException; use Quantum\Di\Exceptions\DiException; use Psr\SimpleCache\CacheInterface; +use Quantum\Cache\Traits\CacheTrait; use Quantum\Storage\FileSystem; use InvalidArgumentException; use ReflectionException; @@ -32,17 +33,9 @@ */ class FileAdapter implements CacheInterface { - private FileSystem $fs; - - /** - * @var int - */ - private $ttl; + use CacheTrait; - /** - * @var string - */ - private $prefix; + private FileSystem $fs; /** * @var string @@ -119,7 +112,7 @@ public function has($key): bool return false; } - if (time() - $this->fs->lastModified($path) > $this->ttl) { + if (time() >= $this->fs->lastModified($path)) { $this->delete($key); return false; } @@ -130,9 +123,16 @@ public function has($key): bool /** * @inheritDoc */ - public function set($key, $value, $ttl = null) + public function set($key, $value, $ttl = null): bool { - return $this->fs->put($this->getPath($key), serialize($value)); + $path = $this->getPath($key); + $result = $this->fs->put($path, serialize($value)) !== false; + + if ($result) { + touch($path, time() + $this->normalizeTtl($ttl)); + } + + return $result; } /** @@ -158,7 +158,7 @@ public function setMultiple($values, $ttl = null): bool /** * @inheritDoc */ - public function delete($key) + public function delete($key): bool { $path = $this->getPath($key); @@ -216,6 +216,6 @@ public function clear(): bool */ private function getPath(string $key): string { - return $this->cacheDir . DS . sha1($this->prefix . $key); + return $this->cacheDir . DS . $this->keyHash($key); } } diff --git a/src/Cache/Adapters/MemcachedAdapter.php b/src/Cache/Adapters/MemcachedAdapter.php index f682cded..eb8a5448 100644 --- a/src/Cache/Adapters/MemcachedAdapter.php +++ b/src/Cache/Adapters/MemcachedAdapter.php @@ -19,6 +19,7 @@ use Quantum\Cache\Exceptions\CacheException; use Quantum\Cache\Enums\ExceptionMessages; use Quantum\App\Exceptions\BaseException; +use Quantum\Cache\Traits\CacheTrait; use Psr\SimpleCache\CacheInterface; use InvalidArgumentException; use Memcached; @@ -30,15 +31,7 @@ */ class MemcachedAdapter implements CacheInterface { - /** - * @var int - */ - private $ttl; - - /** - * @var string - */ - private $prefix; + use CacheTrait; private Memcached $memcached; @@ -87,7 +80,10 @@ public function get($key, $default = null) public function getMultiple($keys, $default = null) { if (!is_array($keys)) { - throw new InvalidArgumentException(_message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$values'), E_WARNING); + throw new InvalidArgumentException( + _message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$keys'), + E_WARNING + ); } $result = []; @@ -113,7 +109,7 @@ public function has($key): bool */ public function set($key, $value, $ttl = null): bool { - return $this->memcached->set($this->keyHash($key), serialize($value), $this->ttl); + return $this->memcached->set($this->keyHash($key), serialize($value), $this->normalizeTtl($ttl)); } /** @@ -124,7 +120,10 @@ public function set($key, $value, $ttl = null): bool public function setMultiple($values, $ttl = null): bool { if (!is_array($values)) { - throw new InvalidArgumentException(_message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$values'), E_WARNING); + throw new InvalidArgumentException( + _message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$values'), + E_WARNING + ); } $results = []; @@ -152,7 +151,10 @@ public function delete($key): bool public function deleteMultiple($keys): bool { if (!is_array($keys)) { - throw new InvalidArgumentException(_message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$values'), E_WARNING); + throw new InvalidArgumentException( + _message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$keys'), + E_WARNING + ); } $results = []; @@ -172,11 +174,4 @@ public function clear(): bool return $this->memcached->flush(); } - /** - * Gets the hashed key - */ - private function keyHash(string $key): string - { - return sha1($this->prefix . $key); - } } diff --git a/src/Cache/Adapters/RedisAdapter.php b/src/Cache/Adapters/RedisAdapter.php index c384a120..0b63ab72 100644 --- a/src/Cache/Adapters/RedisAdapter.php +++ b/src/Cache/Adapters/RedisAdapter.php @@ -17,6 +17,7 @@ namespace Quantum\Cache\Adapters; use Quantum\Cache\Enums\ExceptionMessages; +use Quantum\Cache\Traits\CacheTrait; use Psr\SimpleCache\CacheInterface; use InvalidArgumentException; use RedisException; @@ -29,15 +30,7 @@ */ class RedisAdapter implements CacheInterface { - /** - * @var int - */ - private $ttl; - - /** - * @var string - */ - private $prefix; + use CacheTrait; private Redis $redis; @@ -82,7 +75,10 @@ public function get($key, $default = null) public function getMultiple($keys, $default = null) { if (!is_array($keys)) { - throw new InvalidArgumentException(_message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$values'), E_WARNING); + throw new InvalidArgumentException( + _message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$keys'), + E_WARNING + ); } $result = []; @@ -107,9 +103,13 @@ public function has($key): bool * @inheritDoc * @throws RedisException */ - public function set($key, $value, $ttl = null) + public function set($key, $value, $ttl = null): bool { - return $this->redis->set($this->keyHash($key), serialize($value), $this->ttl); + return (bool) $this->redis->set( + $this->keyHash($key), + serialize($value), + $this->normalizeTtl($ttl) + ); } /** @@ -121,7 +121,10 @@ public function set($key, $value, $ttl = null) public function setMultiple($values, $ttl = null): bool { if (!is_array($values)) { - throw new InvalidArgumentException(_message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$values'), E_WARNING); + throw new InvalidArgumentException( + _message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$values'), + E_WARNING + ); } $results = []; @@ -149,7 +152,10 @@ public function delete($key): bool public function deleteMultiple($keys): bool { if (!is_array($keys)) { - throw new InvalidArgumentException(_message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$values'), E_WARNING); + throw new InvalidArgumentException( + _message(ExceptionMessages::ARGUMENT_NOT_ITERABLE, '$keys'), + E_WARNING + ); } $results = []; @@ -165,16 +171,8 @@ public function deleteMultiple($keys): bool * @inheritDoc * @throws RedisException */ - public function clear() - { - return $this->redis->flushdb(); - } - - /** - * Gets the hashed key - */ - private function keyHash(string $key): string + public function clear(): bool { - return sha1($this->prefix . $key); + return (bool) $this->redis->flushdb(); } } diff --git a/src/Cache/Factories/CacheFactory.php b/src/Cache/Factories/CacheFactory.php index d67b5738..7f2cc130 100644 --- a/src/Cache/Factories/CacheFactory.php +++ b/src/Cache/Factories/CacheFactory.php @@ -24,6 +24,7 @@ use Quantum\Cache\Adapters\RedisAdapter; use Quantum\Cache\Adapters\FileAdapter; use Quantum\Di\Exceptions\DiException; +use Psr\SimpleCache\CacheInterface; use Quantum\Cache\Enums\CacheType; use Quantum\Loader\Setup; use ReflectionException; @@ -73,9 +74,18 @@ public static function get(?string $adapter = null): Cache return self::$instances[$adapter]; } + /** + * @throws CacheException + */ private static function createInstance(string $adapterClass, string $adapter): Cache { - return new Cache(new $adapterClass(config()->get('cache.' . $adapter))); + $cacheAdapter = new $adapterClass(config()->get('cache.' . $adapter)); + + if (!$cacheAdapter instanceof CacheInterface) { + throw CacheException::adapterNotSupported($adapter); + } + + return new Cache($cacheAdapter); } /** diff --git a/src/Cache/Traits/CacheTrait.php b/src/Cache/Traits/CacheTrait.php new file mode 100644 index 00000000..de64e695 --- /dev/null +++ b/src/Cache/Traits/CacheTrait.php @@ -0,0 +1,66 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\Cache\Traits; + +use DateTimeImmutable; +use DateInterval; + +/** + * Trait CacheTrait + * @package Quantum\Cache\Traits + */ +trait CacheTrait +{ + /** + * @var int + */ + protected $ttl; + + /** + * @var string + */ + protected $prefix; + + /** + * Gets the hashed key + */ + protected function keyHash(string $key): string + { + return sha1($this->prefix . $key); + } + + /** + * Normalizes the TTL + * @param int|DateInterval|null $ttl + * @return int + */ + protected function normalizeTtl($ttl): int + { + if ($ttl instanceof DateInterval) { + $now = new DateTimeImmutable(); + $future = $now->add($ttl); + + return max(0, $future->getTimestamp() - $now->getTimestamp()); + } + + if ($ttl === null) { + return $this->ttl; + } + + return (int) $ttl; + } +} diff --git a/tests/Unit/Cache/Adapters/DatabaseAdapterTest.php b/tests/Unit/Cache/Adapters/DatabaseAdapterTest.php index 473425a8..8b1bf132 100644 --- a/tests/Unit/Cache/Adapters/DatabaseAdapterTest.php +++ b/tests/Unit/Cache/Adapters/DatabaseAdapterTest.php @@ -6,6 +6,7 @@ use Quantum\Cache\Adapters\DatabaseAdapter; use Quantum\Tests\Unit\AppTestCase; use Quantum\Loader\Setup; +use DateInterval; class DatabaseAdapterTest extends AppTestCase { @@ -161,4 +162,42 @@ public function testDatabaseAdapterExpired(): void $this->assertNull($databaseCache->get('test')); } + public function testDatabaseAdapterSetWithCustomTtl(): void + { + $this->databaseCache->set('test', 'Test value', 120); + + $this->assertTrue($this->databaseCache->has('test')); + + $this->assertEquals('Test value', $this->databaseCache->get('test')); + } + + public function testDatabaseAdapterSetWithDateIntervalTtl(): void + { + $this->databaseCache->set('test', 'Test value', new DateInterval('PT60S')); + + $this->assertTrue($this->databaseCache->has('test')); + + $this->assertEquals('Test value', $this->databaseCache->get('test')); + } + + public function testDatabaseAdapterExpiredWithCustomTtl(): void + { + $this->databaseCache->set('test', 'Test value', -1); + + $this->assertNull($this->databaseCache->get('test')); + } + + public function testDatabaseAdapterPerKeyTtlIndependence(): void + { + $this->databaseCache->set('short', 'Short TTL', -1); + + $this->databaseCache->set('long', 'Long TTL', 120); + + $this->assertFalse($this->databaseCache->has('short')); + + $this->assertTrue($this->databaseCache->has('long')); + + $this->assertEquals('Long TTL', $this->databaseCache->get('long')); + } + } diff --git a/tests/Unit/Cache/Adapters/FileAdapterTest.php b/tests/Unit/Cache/Adapters/FileAdapterTest.php index 04bb355d..5c3ae43b 100644 --- a/tests/Unit/Cache/Adapters/FileAdapterTest.php +++ b/tests/Unit/Cache/Adapters/FileAdapterTest.php @@ -4,6 +4,7 @@ use Quantum\Cache\Adapters\FileAdapter; use Quantum\Tests\Unit\AppTestCase; +use DateInterval; class FileAdapterTest extends AppTestCase { @@ -138,4 +139,42 @@ public function testFileAdapterExpired(): void $this->assertNull($fileCache->get('test')); } + public function testFileAdapterSetWithCustomTtl(): void + { + $this->fileCache->set('test', 'Test value', 120); + + $this->assertTrue($this->fileCache->has('test')); + + $this->assertEquals('Test value', $this->fileCache->get('test')); + } + + public function testFileAdapterSetWithDateIntervalTtl(): void + { + $this->fileCache->set('test', 'Test value', new DateInterval('PT60S')); + + $this->assertTrue($this->fileCache->has('test')); + + $this->assertEquals('Test value', $this->fileCache->get('test')); + } + + public function testFileAdapterExpiredWithCustomTtl(): void + { + $this->fileCache->set('test', 'Test value', -1); + + $this->assertNull($this->fileCache->get('test')); + } + + public function testFileAdapterPerKeyTtlIndependence(): void + { + $this->fileCache->set('short', 'Short TTL', -1); + + $this->fileCache->set('long', 'Long TTL', 120); + + $this->assertFalse($this->fileCache->has('short')); + + $this->assertTrue($this->fileCache->has('long')); + + $this->assertEquals('Long TTL', $this->fileCache->get('long')); + } + } diff --git a/tests/Unit/Cache/Adapters/MemcachedAdapterTest.php b/tests/Unit/Cache/Adapters/MemcachedAdapterTest.php index 5d17901d..fd63e842 100644 --- a/tests/Unit/Cache/Adapters/MemcachedAdapterTest.php +++ b/tests/Unit/Cache/Adapters/MemcachedAdapterTest.php @@ -4,6 +4,7 @@ use Quantum\Cache\Adapters\MemcachedAdapter; use Quantum\Tests\Unit\AppTestCase; +use DateInterval; class MemcachedAdapterTest extends AppTestCase { @@ -153,4 +154,31 @@ public function testMemcachedAdapterExpired(): void $this->assertNull($memCached->get('test')); } + public function testMemcachedAdapterSetWithCustomTtl(): void + { + $this->memCached->set('test', 'Test value', 120); + + $this->assertTrue($this->memCached->has('test')); + + $this->assertEquals('Test value', $this->memCached->get('test')); + } + + public function testMemcachedAdapterSetWithDateIntervalTtl(): void + { + $this->memCached->set('test', 'Test value', new DateInterval('PT60S')); + + $this->assertTrue($this->memCached->has('test')); + + $this->assertEquals('Test value', $this->memCached->get('test')); + } + + public function testMemcachedAdapterExpiredWithCustomTtl(): void + { + $this->memCached->set('test', 'Test value', 1); + + sleep(2); + + $this->assertNull($this->memCached->get('test')); + } + } diff --git a/tests/Unit/Cache/Adapters/RedisAdapterTest.php b/tests/Unit/Cache/Adapters/RedisAdapterTest.php index 79c69977..f944ab62 100644 --- a/tests/Unit/Cache/Adapters/RedisAdapterTest.php +++ b/tests/Unit/Cache/Adapters/RedisAdapterTest.php @@ -4,6 +4,7 @@ use Quantum\Cache\Adapters\RedisAdapter; use Quantum\Tests\Unit\AppTestCase; +use DateInterval; class RedisAdapterTest extends AppTestCase { @@ -153,4 +154,31 @@ public function testRedisAdapterExpired(): void $this->assertNull($redis->get('test')); } + public function testRedisAdapterSetWithCustomTtl(): void + { + $this->redis->set('test', 'Test value', 120); + + $this->assertTrue($this->redis->has('test')); + + $this->assertEquals('Test value', $this->redis->get('test')); + } + + public function testRedisAdapterSetWithDateIntervalTtl(): void + { + $this->redis->set('test', 'Test value', new DateInterval('PT60S')); + + $this->assertTrue($this->redis->has('test')); + + $this->assertEquals('Test value', $this->redis->get('test')); + } + + public function testRedisAdapterExpiredWithCustomTtl(): void + { + $this->redis->set('test', 'Test value', 1); + + sleep(2); + + $this->assertNull($this->redis->get('test')); + } + } From ad968d4f8be82dc0f566ac914d12548e9fc00f60 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:49:56 +0400 Subject: [PATCH 07/39] Enforce PHPStan level 7 compliance for Captcha package --- src/Captcha/Factories/CaptchaFactory.php | 12 +++++++++++- src/Captcha/Traits/CaptchaTrait.php | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Captcha/Factories/CaptchaFactory.php b/src/Captcha/Factories/CaptchaFactory.php index ad5b8da6..751e4613 100644 --- a/src/Captcha/Factories/CaptchaFactory.php +++ b/src/Captcha/Factories/CaptchaFactory.php @@ -17,6 +17,7 @@ namespace Quantum\Captcha\Factories; use Quantum\Captcha\Exceptions\CaptchaException; +use Quantum\Captcha\Contracts\CaptchaInterface; use Quantum\Captcha\Adapters\RecaptchaAdapter; use Quantum\Config\Exceptions\ConfigException; use Quantum\Captcha\Adapters\HcaptchaAdapter; @@ -70,9 +71,18 @@ public static function get(?string $adapter = null): Captcha return self::$instances[$adapter]; } + /** + * @throws CaptchaException + */ private static function createInstance(string $adapterClass, string $adapter): Captcha { - return new Captcha(new $adapterClass(config()->get('captcha.' . $adapter), new HttpClient())); + $adapterInstance = new $adapterClass(config()->get('captcha.' . $adapter), new HttpClient()); + + if (!$adapterInstance instanceof CaptchaInterface) { + throw CaptchaException::adapterNotSupported($adapter); + } + + return new Captcha($adapterInstance); } /** diff --git a/src/Captcha/Traits/CaptchaTrait.php b/src/Captcha/Traits/CaptchaTrait.php index 4e2ac7de..4ebd4e15 100644 --- a/src/Captcha/Traits/CaptchaTrait.php +++ b/src/Captcha/Traits/CaptchaTrait.php @@ -50,7 +50,7 @@ trait CaptchaTrait protected array $errorCodes = []; /** - * @var array + * @var array */ protected array $elementAttributes = []; From 93a0354556de34052dc4499f3c9af123a9386b58 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:27:37 +0400 Subject: [PATCH 08/39] Fix type safety in Console package for PHPStan level 7 and add unit tests for all commands --- src/Console/Commands/DebugBarCommand.php | 20 ++++++----- .../Commands/InstallToolkitCommand.php | 12 +++++-- src/Console/Commands/KeyGenerateCommand.php | 2 +- src/Console/Commands/OpenApiCommand.php | 11 +++++- .../Commands/ResourceCacheClearCommand.php | 16 ++++++--- src/Console/Commands/RouteListCommand.php | 2 +- src/Console/Commands/ServeCommand.php | 2 +- src/Console/QtCommand.php | 35 ++++++++++++++----- 8 files changed, 72 insertions(+), 28 deletions(-) diff --git a/src/Console/Commands/DebugBarCommand.php b/src/Console/Commands/DebugBarCommand.php index 6e0b0b40..ba501c48 100644 --- a/src/Console/Commands/DebugBarCommand.php +++ b/src/Console/Commands/DebugBarCommand.php @@ -18,9 +18,7 @@ use Quantum\Storage\Exceptions\FileSystemException; use Quantum\Storage\Factories\FileSystemFactory; -use Quantum\Config\Exceptions\ConfigException; use Quantum\App\Exceptions\BaseException; -use Quantum\Di\Exceptions\DiException; use Quantum\Storage\FileSystem; use Quantum\Console\QtCommand; use ReflectionException; @@ -34,7 +32,7 @@ class DebugBarCommand extends QtCommand /** * File System */ - protected ?FileSystem $fs = null; + protected FileSystem $fs; /** * Command name @@ -61,17 +59,21 @@ class DebugBarCommand extends QtCommand */ private string $vendorDebugBarFolderPath = 'vendor/php-debugbar/php-debugbar/src/DebugBar/Resources'; + /** + * @throws BaseException|ReflectionException + */ + public function __construct() + { + parent::__construct(); + $this->fs = FileSystemFactory::get(); + } + /** * Executes the command and publishes the debug bar assets - * @throws BaseException - * @throws FileSystemException - * @throws ConfigException - * @throws DiException - * @throws ReflectionException + * @throws FileSystemException|BaseException */ public function exec(): void { - $this->fs = FileSystemFactory::get(); if ($this->fs->exists(assets_dir() . DS . 'DebugBar' . DS . 'Resources' . DS . 'debugbar.css')) { $this->error('The debug ber already installed'); diff --git a/src/Console/Commands/InstallToolkitCommand.php b/src/Console/Commands/InstallToolkitCommand.php index 790ed7fe..82ab3878 100644 --- a/src/Console/Commands/InstallToolkitCommand.php +++ b/src/Console/Commands/InstallToolkitCommand.php @@ -22,6 +22,7 @@ use Symfony\Component\Console\Input\ArrayInput; use Quantum\Environment\Environment; use Quantum\Console\QtCommand; +use RuntimeException; /** * Class InstallToolkitCommand @@ -88,13 +89,18 @@ public function exec(): void /** * Runs an external command - * @throws ExceptionInterface * @param array $arguments - * @return void + * @throws ExceptionInterface */ protected function runExternalCommand(string $commandName, array $arguments): void { - $command = $this->getApplication()->find($commandName); + $application = $this->getApplication(); + + if ($application === null) { + throw new RuntimeException('Application is not set.'); + } + + $command = $application->find($commandName); $command->run(new ArrayInput($arguments), new NullOutput()); } } diff --git a/src/Console/Commands/KeyGenerateCommand.php b/src/Console/Commands/KeyGenerateCommand.php index 10fe0b72..2a99090d 100644 --- a/src/Console/Commands/KeyGenerateCommand.php +++ b/src/Console/Commands/KeyGenerateCommand.php @@ -77,6 +77,6 @@ public function exec(): void */ private function generateRandomKey(): string { - return bin2hex(random_bytes((int) $this->getOption('length'))); + return bin2hex(random_bytes(max(1, (int) $this->getOption('length')))); } } diff --git a/src/Console/Commands/OpenApiCommand.php b/src/Console/Commands/OpenApiCommand.php index a9fbbff8..5df57383 100644 --- a/src/Console/Commands/OpenApiCommand.php +++ b/src/Console/Commands/OpenApiCommand.php @@ -130,7 +130,11 @@ public function exec(): void } if (!route_group_exists('openapi', $module)) { - $this->fs->put($routes, str_replace('return function ($route) {', $this->openapiRoutes($module), $this->fs->get($routes))); + $routeContent = $this->fs->get($routes); + + if ($routeContent !== false) { + $this->fs->put($routes, str_replace('return function ($route) {', $this->openapiRoutes($module), $routeContent)); + } } if (!$this->fs->isDirectory($modulePath . DS . 'resources' . DS . 'openapi')) { @@ -169,6 +173,11 @@ private function generateOpenapiSpecification(string $module): void $openApi = Generator::scan([$annotationPath]); + if ($openApi === null) { + $this->error('Failed to generate OpenAPI specification.'); + return; + } + $this->fs->put($specPath, $openApi->toJson()); $this->info('OpenAPI specification generated successfully.'); diff --git a/src/Console/Commands/ResourceCacheClearCommand.php b/src/Console/Commands/ResourceCacheClearCommand.php index 3705fe9b..631b135f 100644 --- a/src/Console/Commands/ResourceCacheClearCommand.php +++ b/src/Console/Commands/ResourceCacheClearCommand.php @@ -17,6 +17,7 @@ namespace Quantum\Console\Commands; use Quantum\Storage\Factories\FileSystemFactory; +use Quantum\Loader\Exceptions\LoaderException; use Quantum\Config\Exceptions\ConfigException; use Quantum\App\Exceptions\BaseException; use Quantum\Di\Exceptions\DiException; @@ -73,7 +74,16 @@ class ResourceCacheClearCommand extends QtCommand protected string $cacheDir = ''; - protected ?FileSystem $fs = null; + protected FileSystem $fs; + + /** + * @throws BaseException|ReflectionException + */ + public function __construct() + { + parent::__construct(); + $this->fs = FileSystemFactory::get(); + } /** * @throws BaseException|ReflectionException @@ -89,8 +99,6 @@ public function exec(): void return; } - $this->fs = FileSystemFactory::get(); - if (!$this->fs->isDirectory($this->cacheDir)) { $this->error('Cache directory does not exist or is not accessible.'); return; @@ -127,7 +135,7 @@ private function importModules(): void /** * @throws ConfigException * @throws DiException - * @throws ReflectionException + * @throws ReflectionException|LoaderException */ private function importConfig(): void { diff --git a/src/Console/Commands/RouteListCommand.php b/src/Console/Commands/RouteListCommand.php index 2f841c52..a1db75ec 100644 --- a/src/Console/Commands/RouteListCommand.php +++ b/src/Console/Commands/RouteListCommand.php @@ -93,7 +93,7 @@ static function (Route $route) use ($module): bool { $rows[] = $this->composeTableRow($route, 50); } - $table = new Table($this->output); + $table = new Table($this->resolveOutput()); $table->setHeaderTitle('Routes') ->setHeaders(['MODULE', 'METHOD', 'URI', 'ACTION', 'MIDDLEWARE']) diff --git a/src/Console/Commands/ServeCommand.php b/src/Console/Commands/ServeCommand.php index f30d11e3..61f5d351 100644 --- a/src/Console/Commands/ServeCommand.php +++ b/src/Console/Commands/ServeCommand.php @@ -229,7 +229,7 @@ protected function waitUntilServerIsReady(string $host, int $port, $process): vo */ protected function waitForProcess($process): void { - while (proc_get_status($process)['running']) { + while (($status = proc_get_status($process)) !== false && $status['running']) { usleep(200_000); } diff --git a/src/Console/QtCommand.php b/src/Console/QtCommand.php index d78cd21b..4ec38fa0 100644 --- a/src/Console/QtCommand.php +++ b/src/Console/QtCommand.php @@ -23,6 +23,7 @@ use Symfony\Component\Console\Input\InputOption; use Quantum\Console\Contracts\CommandInterface; use Symfony\Component\Console\Command\Command; +use RuntimeException; /** * Class QtCommand @@ -85,7 +86,7 @@ public function __construct() */ public function getArgument(?string $key = null) { - return $this->input->getArgument($key) ?? ''; + return $this->resolveInput()->getArgument($key ?? '') ?? ''; } /** @@ -94,7 +95,7 @@ public function getArgument(?string $key = null) */ public function getOption(?string $key = null) { - return $this->input->getOption($key) ?? ''; + return $this->resolveInput()->getOption($key ?? '') ?? ''; } /** @@ -102,7 +103,7 @@ public function getOption(?string $key = null) */ public function output(string $message): void { - $this->output->writeln($message); + $this->resolveOutput()->writeln($message); } /** @@ -110,7 +111,7 @@ public function output(string $message): void */ public function info(string $message): void { - $this->output->writeln("$message"); + $this->resolveOutput()->writeln("$message"); } /** @@ -118,7 +119,7 @@ public function info(string $message): void */ public function comment(string $message): void { - $this->output->writeln("$message"); + $this->resolveOutput()->writeln("$message"); } /** @@ -126,7 +127,7 @@ public function comment(string $message): void */ public function question(string $message): void { - $this->output->writeln("$message"); + $this->resolveOutput()->writeln("$message"); } /** @@ -138,7 +139,7 @@ public function confirm(string $message): bool $question = new ConfirmationQuestion($message . ' [y/N] ', false); - return (bool) $helper->ask($this->input, $this->output, $question); + return (bool) $helper->ask($this->resolveInput(), $this->resolveOutput(), $question); } /** @@ -146,7 +147,25 @@ public function confirm(string $message): bool */ public function error(string $message): void { - $this->output->writeln("$message"); + $this->resolveOutput()->writeln("$message"); + } + + protected function resolveInput(): InputInterface + { + if ($this->input === null) { + throw new RuntimeException('Input is not set.'); + } + + return $this->input; + } + + protected function resolveOutput(): OutputInterface + { + if ($this->output === null) { + throw new RuntimeException('Output is not set.'); + } + + return $this->output; } /** From aa7ab228b365915ae6598c26e3f83d1be23ebd6d Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:30:28 +0400 Subject: [PATCH 09/39] Enforce PHPStan level 7 compliance for Cron package --- src/Cron/CronLock.php | 6 ++++-- src/Cron/CronTask.php | 4 ++-- src/Cron/Schedule.php | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Cron/CronLock.php b/src/Cron/CronLock.php index 99e98daa..5e55841f 100644 --- a/src/Cron/CronLock.php +++ b/src/Cron/CronLock.php @@ -53,13 +53,15 @@ public function __construct(string $taskName, ?string $lockDirectory = null, ?in public function acquire(): bool { - $this->lockHandle = fopen($this->lockFile, 'c+'); - if ($this->lockHandle === false) { + $handle = fopen($this->lockFile, 'c+'); + if ($handle === false) { $this->lockHandle = null; $this->ownsLock = false; return false; } + $this->lockHandle = $handle; + if (!flock($this->lockHandle, LOCK_EX | LOCK_NB)) { fclose($this->lockHandle); $this->lockHandle = null; diff --git a/src/Cron/CronTask.php b/src/Cron/CronTask.php index 33a2aa08..de942111 100644 --- a/src/Cron/CronTask.php +++ b/src/Cron/CronTask.php @@ -29,7 +29,7 @@ class CronTask implements CronTaskInterface /** * Cron expression instance */ - private ?CronExpression $cronExpression = null; + private CronExpression $cronExpression; /** * Task name @@ -63,7 +63,7 @@ public function __construct(string $name, string $expression, callable $callback */ public function getExpression(): string { - return $this->cronExpression->getExpression(); + return $this->cronExpression->getExpression() ?? ''; } /** diff --git a/src/Cron/Schedule.php b/src/Cron/Schedule.php index f8bd068d..b4a584ba 100644 --- a/src/Cron/Schedule.php +++ b/src/Cron/Schedule.php @@ -350,7 +350,7 @@ public function at(string $time): self $minute = (int) $minute; // Replace hour and minute in existing expression - $parts = explode(' ', $this->expression); + $parts = explode(' ', $this->expression ?? ''); $parts[0] = (string) $minute; $parts[1] = (string) $hour; $this->expression = implode(' ', $parts); From 575479e8980840d5c13d0712e2e45ed7d65bac85 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:33:36 +0400 Subject: [PATCH 10/39] Enforce PHPStan level 7 compliance for Csrf package --- src/Csrf/Csrf.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Csrf/Csrf.php b/src/Csrf/Csrf.php index b7cb156e..e4614afe 100644 --- a/src/Csrf/Csrf.php +++ b/src/Csrf/Csrf.php @@ -87,7 +87,7 @@ public function generateToken(string $key): ?string * Checks the token * @throws CsrfException */ - public function checkToken(?Request $request): bool + public function checkToken(Request $request): bool { if (!$request->has(self::TOKEN_KEY)) { throw CsrfException::tokenNotFound(); From 22e9a42b30ce350bc8c9cef3aca5c079c6954050 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:46:33 +0400 Subject: [PATCH 11/39] Fix type safety in Database package for PHPStan level 7 and add unserialize guard in SleekDB Join --- src/Database/Adapters/Idiorm/IdiormDbal.php | 13 +++++---- src/Database/Adapters/Idiorm/IdiormPatch.php | 4 +-- .../Adapters/Idiorm/Statements/Query.php | 10 +++++-- .../Adapters/Idiorm/Statements/Result.php | 7 +++-- src/Database/Adapters/Sleekdb/SleekDbal.php | 29 ++++++++++--------- .../Adapters/Sleekdb/Statements/Join.php | 7 ++++- .../Adapters/Sleekdb/Statements/Model.php | 3 +- .../Adapters/Sleekdb/Statements/Result.php | 2 +- 8 files changed, 48 insertions(+), 27 deletions(-) diff --git a/src/Database/Adapters/Idiorm/IdiormDbal.php b/src/Database/Adapters/Idiorm/IdiormDbal.php index 7a0f384f..1106fd41 100644 --- a/src/Database/Adapters/Idiorm/IdiormDbal.php +++ b/src/Database/Adapters/Idiorm/IdiormDbal.php @@ -67,7 +67,7 @@ class IdiormDbal implements DbalInterface, RelationalInterface /** * Associated model name - * @var string + * @var string|null */ private $modelName; @@ -202,15 +202,18 @@ public function getTable(): string */ public function getOrmModel(): ORM { - if (!$this->ormModel) { + $model = $this->ormModel; + + if (!$model) { if (!self::getConnection()) { throw DatabaseException::missingConfig('database'); } - $this->ormModel = (self::$ormClass)::for_table($this->table)->use_id_column($this->idColumn); + $model = (self::$ormClass)::for_table($this->table)->use_id_column($this->idColumn); + $this->ormModel = $model; } - return $this->ormModel; + return $model; } /** @@ -227,7 +230,7 @@ public function getForeignKeys(): array */ public function getModelName(): string { - return $this->modelName; + return $this->modelName ?? ''; } /** diff --git a/src/Database/Adapters/Idiorm/IdiormPatch.php b/src/Database/Adapters/Idiorm/IdiormPatch.php index fec4ca40..008635ae 100644 --- a/src/Database/Adapters/Idiorm/IdiormPatch.php +++ b/src/Database/Adapters/Idiorm/IdiormPatch.php @@ -25,7 +25,7 @@ */ class IdiormPatch extends ORM { - private ?object $ormModel = null; + private ?ORM $ormModel = null; private static ?IdiormPatch $instance = null; @@ -44,7 +44,7 @@ public static function getInstance(): IdiormPatch /** * Set ORM Object */ - public function use(object $ormModel): IdiormPatch + public function use(ORM $ormModel): IdiormPatch { $this->ormModel = $ormModel; return $this; diff --git a/src/Database/Adapters/Idiorm/Statements/Query.php b/src/Database/Adapters/Idiorm/Statements/Query.php index 68ce30bf..15557a98 100644 --- a/src/Database/Adapters/Idiorm/Statements/Query.php +++ b/src/Database/Adapters/Idiorm/Statements/Query.php @@ -17,6 +17,7 @@ namespace Quantum\Database\Adapters\Idiorm\Statements; use Quantum\Database\Exceptions\DatabaseException; +use PDOStatement; use PDOException; /** @@ -118,8 +119,13 @@ public static function fetchColumns(string $table): array self::query('SELECT * FROM ' . $table); $statement = self::lastStatement(); - for ($i = 0; $i < $statement->columnCount(); $i++) { - $columns[] = $statement->getColumnMeta($i)['name']; + if ($statement instanceof PDOStatement) { + for ($i = 0; $i < $statement->columnCount(); $i++) { + $meta = $statement->getColumnMeta($i); + if (is_array($meta)) { + $columns[] = $meta['name']; + } + } } return $columns; diff --git a/src/Database/Adapters/Idiorm/Statements/Result.php b/src/Database/Adapters/Idiorm/Statements/Result.php index 61187cc1..01fb1071 100644 --- a/src/Database/Adapters/Idiorm/Statements/Result.php +++ b/src/Database/Adapters/Idiorm/Statements/Result.php @@ -32,11 +32,14 @@ trait Result */ public function get(): array { + $results = $this->getOrmModel()->find_many(); + $items = is_array($results) ? $results : iterator_to_array($results); + return array_map(function ($element): object { $item = clone $this; $item->updateOrmModel($element); return $item; - }, $this->getOrmModel()->find_many()); + }, $items); } /** @@ -110,7 +113,7 @@ public function asArray(): array * @param array $result * @return array */ - public function setHidden($result): array + public function setHidden(array $result): array { return array_diff_key($result, array_flip($this->hidden)); } diff --git a/src/Database/Adapters/Sleekdb/SleekDbal.php b/src/Database/Adapters/Sleekdb/SleekDbal.php index 7127b7ca..62c2df34 100644 --- a/src/Database/Adapters/Sleekdb/SleekDbal.php +++ b/src/Database/Adapters/Sleekdb/SleekDbal.php @@ -263,8 +263,8 @@ public function getOrmModel(): Store */ public function updateOrmModel(?array $modelData): void { - $this->data = $modelData; - $this->modifiedFields = $modelData; + $this->data = $modelData ?? []; + $this->modifiedFields = $modelData ?? []; $this->isNew = false; } @@ -292,12 +292,15 @@ public function truncate(): bool */ public function getBuilder(): QueryBuilder { - if (!$this->queryBuilder) { - $this->queryBuilder = $this->getOrmModel()->createQueryBuilder(); + $builder = $this->queryBuilder; + + if (!$builder) { + $builder = $this->getOrmModel()->createQueryBuilder(); + $this->queryBuilder = $builder; } if ($this->selected !== []) { - $this->queryBuilder->select($this->selected); + $builder->select($this->selected); } if ($this->joins !== []) { @@ -305,30 +308,30 @@ public function getBuilder(): QueryBuilder } if ($this->criterias !== []) { - $this->queryBuilder->where($this->criterias); + $builder->where($this->criterias); } if ($this->havings !== []) { - $this->queryBuilder->having($this->havings); + $builder->having($this->havings); } if ($this->grouped !== []) { - $this->queryBuilder->groupBy($this->grouped); + $builder->groupBy($this->grouped); } if ($this->ordered !== []) { - $this->queryBuilder->orderBy($this->ordered); + $builder->orderBy($this->ordered); } if ($this->offset) { - $this->queryBuilder->skip($this->offset); + $builder->skip($this->offset); } if ($this->limit) { - $this->queryBuilder->limit($this->limit); + $builder->limit($this->limit); } - return $this->queryBuilder; + return $builder; } /** @@ -345,7 +348,7 @@ public function getForeignKeys(): array */ public function getModelName(): string { - return $this->modelName; + return $this->modelName ?? ''; } /** diff --git a/src/Database/Adapters/Sleekdb/Statements/Join.php b/src/Database/Adapters/Sleekdb/Statements/Join.php index 34563cf7..ffb5fc20 100644 --- a/src/Database/Adapters/Sleekdb/Statements/Join.php +++ b/src/Database/Adapters/Sleekdb/Statements/Join.php @@ -23,6 +23,7 @@ use Quantum\Database\Enums\Relation; use Quantum\Model\DbModel; use SleekDB\QueryBuilder; +use RuntimeException; /** * Trait Join @@ -49,7 +50,7 @@ public function joinTo(DbModel $model, bool $switch = true): DbalInterface */ private function applyJoins(): void { - if (!empty($this->joins)) { + if (!empty($this->joins) && $this->queryBuilder !== null) { $this->applyJoin($this->queryBuilder, $this, $this->joins[0]); } } @@ -64,6 +65,10 @@ private function applyJoin(QueryBuilder $queryBuilder, SleekDbal $currentItem, a $modelToJoin = unserialize($nextItem['model']); $switch = $nextItem['switch']; + if (!is_object($modelToJoin)) { + throw new RuntimeException('Failed to unserialize join model.'); + } + $queryBuilder->join(function ($item) use ($currentItem, $modelToJoin, $switch, $level) { $sleekModel = new self( diff --git a/src/Database/Adapters/Sleekdb/Statements/Model.php b/src/Database/Adapters/Sleekdb/Statements/Model.php index d752e13e..5bd430a7 100644 --- a/src/Database/Adapters/Sleekdb/Statements/Model.php +++ b/src/Database/Adapters/Sleekdb/Statements/Model.php @@ -100,6 +100,7 @@ public function delete(): bool */ public function deleteMany(): bool { - return $this->getBuilder()->getQuery()->delete(); + $result = $this->getBuilder()->getQuery()->delete(); + return is_bool($result) ? $result : $result !== 0; } } diff --git a/src/Database/Adapters/Sleekdb/Statements/Result.php b/src/Database/Adapters/Sleekdb/Statements/Result.php index cf34687d..e1bbc70f 100644 --- a/src/Database/Adapters/Sleekdb/Statements/Result.php +++ b/src/Database/Adapters/Sleekdb/Statements/Result.php @@ -143,7 +143,7 @@ public function asArray(): array * @param array $result * @return array */ - public function setHidden($result): array + public function setHidden(array $result): array { return array_diff_key($result, array_flip($this->hidden)); } From 72d2d1cbe65def1bc342c6de26abdda1a38fda0f Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:47:45 +0400 Subject: [PATCH 12/39] Enforce PHPStan level 7 compliance for Di package --- src/Di/Di.php | 7 ++++--- src/Di/Exceptions/DiException.php | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Di/Di.php b/src/Di/Di.php index f1c77d9c..03798c07 100644 --- a/src/Di/Di.php +++ b/src/Di/Di.php @@ -31,17 +31,17 @@ class Di { /** - * @var array + * @var array */ private static array $dependencies = []; /** - * @var array + * @var array */ private static array $container = []; /** - * @var array + * @var array */ private static array $resolving = []; @@ -219,6 +219,7 @@ private static function resolve(string $abstract, array $args = [], bool $single /** * Instantiates the dependency + * @param class-string $concrete * @param array $args * @return mixed * @throws ReflectionException|DiException diff --git a/src/Di/Exceptions/DiException.php b/src/Di/Exceptions/DiException.php index 63fe899e..034c3e45 100644 --- a/src/Di/Exceptions/DiException.php +++ b/src/Di/Exceptions/DiException.php @@ -68,7 +68,7 @@ public static function circularDependency(string $chain): DiException public static function invalidCallable(?string $entry = null): DiException { return new self( - _message(ExceptionMessages::INVALID_CALLABLE, [$entry]), + _message(ExceptionMessages::INVALID_CALLABLE, [$entry ?? '']), E_ERROR ); } From 31012dd094d423cbc0738132aa2c699892862b2e Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:49:30 +0400 Subject: [PATCH 13/39] Enforce PHPStan level 7 compliance for Encryption package --- .../Adapters/AsymmetricEncryptionAdapter.php | 9 ++++++- .../Adapters/SymmetricEncryptionAdapter.php | 25 +++++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/Encryption/Adapters/AsymmetricEncryptionAdapter.php b/src/Encryption/Adapters/AsymmetricEncryptionAdapter.php index f05b26c4..d42d0104 100644 --- a/src/Encryption/Adapters/AsymmetricEncryptionAdapter.php +++ b/src/Encryption/Adapters/AsymmetricEncryptionAdapter.php @@ -103,7 +103,14 @@ private function generateKeyPair(): array } openssl_pkey_export($resource, $privateKey); - $publicKey = openssl_pkey_get_details($resource)['key']; + + $details = openssl_pkey_get_details($resource); + + if ($details === false) { + throw CryptorException::missingConfig('openssl.cnf'); + } + + $publicKey = $details['key']; return [ 'private' => $privateKey, diff --git a/src/Encryption/Adapters/SymmetricEncryptionAdapter.php b/src/Encryption/Adapters/SymmetricEncryptionAdapter.php index 1bcdf44e..5d1aed22 100644 --- a/src/Encryption/Adapters/SymmetricEncryptionAdapter.php +++ b/src/Encryption/Adapters/SymmetricEncryptionAdapter.php @@ -60,6 +60,10 @@ public function encrypt(string $plain): string $encrypted = openssl_encrypt($plain, self::CIPHER_METHOD, $this->appKey, 0, $iv); + if ($encrypted === false) { + throw CryptorException::invalidCipher(); + } + return base64_encode(base64_encode($encrypted) . '::' . base64_encode($iv)); } @@ -82,7 +86,13 @@ public function decrypt(string $encrypted): string $encryptedData = base64_decode($data[0]); $iv = base64_decode($data[1]); - return openssl_decrypt($encryptedData, self::CIPHER_METHOD, $this->appKey, 0, $iv); + $decrypted = openssl_decrypt($encryptedData, self::CIPHER_METHOD, $this->appKey, 0, $iv); + + if ($decrypted === false) { + throw CryptorException::invalidCipher(); + } + + return $decrypted; } /** @@ -91,6 +101,17 @@ public function decrypt(string $encrypted): string private function generateIV(): string { $length = openssl_cipher_iv_length(self::CIPHER_METHOD); - return openssl_random_pseudo_bytes($length); + + if ($length === false) { + throw CryptorException::invalidCipher(); + } + + $bytes = openssl_random_pseudo_bytes($length); + + if ($bytes === false) { + throw CryptorException::invalidCipher(); + } + + return $bytes; } } From e79dcdf77428ae6e688eea200b366b06b9f71d6d Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:50:08 +0400 Subject: [PATCH 14/39] Enforce PHPStan level 7 compliance for Environment package --- src/Environment/Environment.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Environment/Environment.php b/src/Environment/Environment.php index e8ef37a2..ce4afe5c 100644 --- a/src/Environment/Environment.php +++ b/src/Environment/Environment.php @@ -170,6 +170,11 @@ public function updateRow(string $key, ?string $value): void if ($row) { $envFileContent = fs()->get($envFilePath); + + if (!is_string($envFileContent)) { + throw EnvException::fileNotFound($this->envFile); + } + $envFileContent = preg_replace('/^' . preg_quote($row, '/') . '/m', $key . '=' . $value, $envFileContent); fs()->put($envFilePath, (string) $envFileContent); From a630873aabef81895c7d5d72a2a3da9752a79c10 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:59:31 +0400 Subject: [PATCH 15/39] Fix type safety in Http package for PHPStan level 7 and add precise return type for getParsedFile --- src/Http/Request/HttpRequest.php | 2 +- src/Http/Traits/Request/Params.php | 8 ++++---- src/Http/Traits/Request/RawInput.php | 17 ++++++++++++----- src/Http/Traits/Request/Url.php | 3 ++- src/Http/Traits/Response/Body.php | 12 +++++++++--- 5 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/Http/Request/HttpRequest.php b/src/Http/Request/HttpRequest.php index 00eb292d..4369f68c 100644 --- a/src/Http/Request/HttpRequest.php +++ b/src/Http/Request/HttpRequest.php @@ -174,7 +174,7 @@ public static function setMethod(string $method): void */ public static function isMethod(string $method): bool { - return strcasecmp($method, self::$__method) === 0; + return strcasecmp($method, self::$__method ?? '') === 0; } /** diff --git a/src/Http/Traits/Request/Params.php b/src/Http/Traits/Request/Params.php index c1adb1d5..6c050ce6 100644 --- a/src/Http/Traits/Request/Params.php +++ b/src/Http/Traits/Request/Params.php @@ -32,7 +32,7 @@ trait Params * Request content type * @var string|null */ - private static $__contentType; + private static ?string $__contentType; /** * Gets the GET params. @@ -57,7 +57,7 @@ private static function postParams(): array return []; } - return filter_input_array(INPUT_POST) ?? []; + return filter_input_array(INPUT_POST) ?: []; } /** @@ -91,7 +91,7 @@ private static function urlEncodedParams(): array parse_str(urldecode(self::getRawInput()), $result); - return $result; + return $result; /** @phpstan-ignore return.type */ } /** @@ -119,6 +119,6 @@ private static function getRawInputParams(): array */ private static function getRawInput(): string { - return file_get_contents('php://input'); + return file_get_contents('php://input') ?: ''; } } diff --git a/src/Http/Traits/Request/RawInput.php b/src/Http/Traits/Request/RawInput.php index 69be8b12..de81eff7 100644 --- a/src/Http/Traits/Request/RawInput.php +++ b/src/Http/Traits/Request/RawInput.php @@ -75,6 +75,11 @@ private static function getBoundary(): ?string private static function getBlocks(string $boundary, string $rawInput): array { $result = preg_split("/-+$boundary/", $rawInput); + + if ($result === false) { + return []; + } + array_pop($result); return $result; @@ -105,12 +110,14 @@ private static function processBlocks(array $blocks): array switch ($type) { case 'file': - [$nameParam, $file] = self::getParsedFile($block); + $parsed = self::getParsedFile($block); - if (!$file) { + if ($parsed === null) { continue 2; } + [$nameParam, $file] = $parsed; + self::addFileToCollection($files, $nameParam, $file); break; @@ -174,12 +181,12 @@ private static function getParsedStream(string $block): array { preg_match('/name=\"([^\"]*)\".*stream[\n|\r]+([^\n\r].*)?$/s', $block, $match); - return [$match[1] => $match[2] ?? '']; + return [($match[1] ?? '') => $match[2] ?? '']; } /** * Gets the parsed file - * @return array|null + * @return array{string, UploadedFile}|null * @throws BaseException * @throws ConfigException * @throws DiException @@ -271,7 +278,7 @@ private static function parseHeaders(string $rawHeaders): array $filename = '-unknown-'; $contentType = ContentType::OCTET_STREAM; - $rawHeaders = preg_replace("/\r\n|\r|\n/", "\n", $rawHeaders); + $rawHeaders = preg_replace("/\r\n|\r|\n/", "\n", $rawHeaders) ?? $rawHeaders; $lines = explode("\n", $rawHeaders); foreach ($lines as $line) { diff --git a/src/Http/Traits/Request/Url.php b/src/Http/Traits/Request/Url.php index 62c962da..87876039 100644 --- a/src/Http/Traits/Request/Url.php +++ b/src/Http/Traits/Request/Url.php @@ -129,7 +129,8 @@ public static function getAllSegments(): array return ['zero_segment']; } - $segments = explode('/', trim(parse_url(self::$__uri)['path'], '/')); + $parsed = parse_url(self::$__uri); + $segments = explode('/', trim(is_array($parsed) ? ($parsed['path'] ?? '') : '', '/')); array_unshift($segments, 'zero_segment'); return $segments; } diff --git a/src/Http/Traits/Response/Body.php b/src/Http/Traits/Response/Body.php index b34fe07a..b322efb5 100644 --- a/src/Http/Traits/Response/Body.php +++ b/src/Http/Traits/Response/Body.php @@ -198,9 +198,15 @@ private static function arrayToXML(array $arr): string self::composeXML($arr, $simpleXML); $dom = new DOMDocument(); - $dom->loadXML($simpleXML->asXML()); + $xml = $simpleXML->asXML(); + + if ($xml === false) { + return ''; + } + + $dom->loadXML($xml); $dom->formatOutput = true; - return $dom->saveXML(); + return $dom->saveXML() ?: ''; } /** @@ -248,7 +254,7 @@ private static function composeXML(array $arr, SimpleXMLElement &$simpleXML): vo */ private static function formatJson(): string { - return json_encode(self::all(), JSON_UNESCAPED_UNICODE); + return json_encode(self::all(), JSON_UNESCAPED_UNICODE) ?: ''; } /** From acec4965d381e5d5a07e315d03543778322ed90e Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:00:34 +0400 Subject: [PATCH 16/39] Enforce PHPStan level 7 compliance for HttpClient package --- src/HttpClient/HttpClient.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/HttpClient/HttpClient.php b/src/HttpClient/HttpClient.php index 59e257b6..98431f5c 100644 --- a/src/HttpClient/HttpClient.php +++ b/src/HttpClient/HttpClient.php @@ -163,6 +163,8 @@ public function getData() /** * Checks if the request is multi cURL + * @phpstan-assert-if-true MultiCurl $this->client + * @phpstan-assert-if-false Curl $this->client */ public function isMultiRequest(): bool { @@ -317,10 +319,12 @@ public function __call(string $method, array $arguments): HttpClient } /** - * @throws ErrorException + * @throws ErrorException|BaseException */ private function startSingleRequest(): void { + $this->ensureSingleRequest(); + $this->client->setOpt(CURLOPT_CUSTOMREQUEST, $this->method); if ($this->data) { @@ -366,6 +370,7 @@ private function formatHeaders(CaseInsensitiveArray $headers): array /** * @throws BaseException + * @phpstan-assert Curl $this->client */ private function ensureSingleRequest(): void { From ff37ec6daf8b02c10400ce3980eaaa0a30535e39 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:01:03 +0400 Subject: [PATCH 17/39] Enforce PHPStan level 7 compliance for Lang package --- src/Lang/Lang.php | 2 +- src/Lang/Translator.php | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Lang/Lang.php b/src/Lang/Lang.php index bb817e0b..c473177f 100644 --- a/src/Lang/Lang.php +++ b/src/Lang/Lang.php @@ -81,7 +81,7 @@ public function load(): void /** * Get translation by key - * @param array|null $params + * @param array|string|null $params */ public function getTranslation(string $key, $params = null): ?string { diff --git a/src/Lang/Translator.php b/src/Lang/Translator.php index 659b85f3..3d5202eb 100644 --- a/src/Lang/Translator.php +++ b/src/Lang/Translator.php @@ -86,6 +86,10 @@ public function loadTranslations(): void */ private function loadFiles(array $files): void { + if ($this->translations === null) { + return; + } + foreach ($files as $file) { $fileName = fs()->fileName($file); @@ -97,7 +101,7 @@ private function loadFiles(array $files): void /** * Get translation by key - * @param array|string|null $params + * @param array|string|null $params */ public function get(string $key, $params = null): string { From e50a9f156f0cd5e9526199f54c1b868c3cdb84e9 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:01:31 +0400 Subject: [PATCH 18/39] Enforce PHPStan level 7 compliance for Loader package --- src/Loader/Loader.php | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/Loader/Loader.php b/src/Loader/Loader.php index dab31beb..83eeae5f 100644 --- a/src/Loader/Loader.php +++ b/src/Loader/Loader.php @@ -79,7 +79,13 @@ public function set(string $property, $value): Loader */ public function loadDir(string $dir): void { - foreach (glob($dir . DS . '*.php') as $filename) { + $files = glob($dir . DS . '*.php'); + + if (!is_array($files)) { + return; + } + + foreach ($files as $filename) { require_once $filename; } } @@ -106,7 +112,7 @@ public function fileExists(): bool } if ($this->hierarchical) { - $filePath = App::getBaseDir() . DS . 'shared' . DS . strtolower($this->pathPrefix) . DS . $this->fileName . '.php'; + $filePath = App::getBaseDir() . DS . 'shared' . DS . strtolower($this->pathPrefix ?? '') . DS . $this->fileName . '.php'; return file_exists($filePath); } @@ -123,13 +129,13 @@ public function getFilePath(): string if (!file_exists($filePath)) { if ($this->hierarchical) { - $filePath = App::getBaseDir() . DS . 'shared' . DS . strtolower($this->pathPrefix) . DS . $this->fileName . '.php'; + $filePath = App::getBaseDir() . DS . 'shared' . DS . strtolower($this->pathPrefix ?? '') . DS . $this->fileName . '.php'; if (!file_exists($filePath)) { - throw new LoaderException(_message($this->exceptionMessage, $this->fileName)); + throw new LoaderException(_message($this->exceptionMessage, $this->fileName ?? '')); } } else { - throw new LoaderException(_message($this->exceptionMessage, $this->fileName)); + throw new LoaderException(_message($this->exceptionMessage, $this->fileName ?? '')); } } From 60d47be3e96a46f87609da546a137e214b4ca5cf Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:02:26 +0400 Subject: [PATCH 19/39] Enforce PHPStan level 7 compliance for Logger package --- src/Logger/Factories/LoggerFactory.php | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Logger/Factories/LoggerFactory.php b/src/Logger/Factories/LoggerFactory.php index 5a935a92..1feaf289 100644 --- a/src/Logger/Factories/LoggerFactory.php +++ b/src/Logger/Factories/LoggerFactory.php @@ -16,6 +16,7 @@ namespace Quantum\Logger\Factories; +use Quantum\Logger\Contracts\ReportableInterface; use Quantum\Logger\Exceptions\LoggerException; use Quantum\Config\Exceptions\ConfigException; use Quantum\Logger\Adapters\MessageAdapter; @@ -84,9 +85,17 @@ public static function get(?string $adapter = null): Logger private static function createInstance(string $adapterClass, string $adapter): Logger { - return $adapter === LoggerType::MESSAGE - ? new Logger(new MessageAdapter()) - : new Logger(new $adapterClass(config()->get('logging.' . $adapter))); + if ($adapter === LoggerType::MESSAGE) { + return new Logger(new MessageAdapter()); + } + + $adapterInstance = new $adapterClass(config()->get('logging.' . $adapter)); + + if (!$adapterInstance instanceof ReportableInterface) { + throw LoggerException::adapterNotSupported($adapter); + } + + return new Logger($adapterInstance); } /** From 0e3cbf314063d05a7a4c84857749e508e8e3518c Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:26:46 +0400 Subject: [PATCH 20/39] Refactor Mailer package for PHPStan level 7: replace conditional logic with polymorphism and enforce proper error reporting --- src/Mailer/Adapters/MailgunAdapter.php | 15 +++- src/Mailer/Adapters/MandrillAdapter.php | 14 ++- src/Mailer/Adapters/ResendAdapter.php | 14 ++- src/Mailer/Adapters/SendgridAdapter.php | 14 ++- src/Mailer/Adapters/SendinblueAdapter.php | 14 ++- src/Mailer/Adapters/SmtpAdapter.php | 65 ++++++++++++-- src/Mailer/Contracts/MailerInterface.php | 2 +- src/Mailer/Factories/MailerFactory.php | 14 ++- src/Mailer/Traits/MailerTrait.php | 102 ++++++++++++---------- 9 files changed, 187 insertions(+), 67 deletions(-) diff --git a/src/Mailer/Adapters/MailgunAdapter.php b/src/Mailer/Adapters/MailgunAdapter.php index e6c56fda..e4dccb62 100644 --- a/src/Mailer/Adapters/MailgunAdapter.php +++ b/src/Mailer/Adapters/MailgunAdapter.php @@ -38,6 +38,8 @@ class MailgunAdapter implements MailerInterface private string $apiUrl = 'https://api.mailgun.net/v3/'; + private HttpClient $httpClient; + /** * @var array */ @@ -58,7 +60,7 @@ public function __construct(array $params) /** * Prepares the data */ - private function prepare(): void + protected function prepare(): void { $this->data['from'] = $this->from['name'] . ' ' . $this->from['email']; @@ -84,7 +86,7 @@ private function prepare(): void } } - private function sendEmail(): bool + protected function sendEmail(): bool { try { $this->httpClient @@ -102,4 +104,13 @@ private function sendEmail(): bool return false; } } + + /** + * @return array + */ + protected function getTransportErrors(): array + { + return $this->httpClient->getErrors(); + } + } diff --git a/src/Mailer/Adapters/MandrillAdapter.php b/src/Mailer/Adapters/MandrillAdapter.php index c75368c4..1b7c8797 100644 --- a/src/Mailer/Adapters/MandrillAdapter.php +++ b/src/Mailer/Adapters/MandrillAdapter.php @@ -36,6 +36,8 @@ class MandrillAdapter implements MailerInterface private string $apiUrl = 'https://mandrillapp.com/api/1.0/messages/send.json'; + private HttpClient $httpClient; + /** * @var array */ @@ -55,7 +57,7 @@ public function __construct(array $params) /** * Prepares the data */ - private function prepare(): void + protected function prepare(): void { $message = []; @@ -84,7 +86,7 @@ private function prepare(): void $this->data['message'] = $message; } - private function sendEmail(): bool + protected function sendEmail(): bool { try { $this->httpClient @@ -98,4 +100,12 @@ private function sendEmail(): bool return false; } } + + /** + * @return array + */ + protected function getTransportErrors(): array + { + return $this->httpClient->getErrors(); + } } diff --git a/src/Mailer/Adapters/ResendAdapter.php b/src/Mailer/Adapters/ResendAdapter.php index d66c7b5d..51400ae0 100644 --- a/src/Mailer/Adapters/ResendAdapter.php +++ b/src/Mailer/Adapters/ResendAdapter.php @@ -31,6 +31,8 @@ class ResendAdapter implements MailerInterface public string $name = 'Resend'; + private HttpClient $httpClient; + /** * @var string|null */ @@ -56,7 +58,7 @@ public function __construct(array $params) /** * Prepares the data */ - private function prepare(): void + protected function prepare(): void { $fromName = $this->from['name'] ?? null; @@ -81,7 +83,7 @@ private function prepare(): void } } - private function sendEmail(): bool + protected function sendEmail(): bool { try { $this->httpClient @@ -99,4 +101,12 @@ private function sendEmail(): bool return false; } } + + /** + * @return array + */ + protected function getTransportErrors(): array + { + return $this->httpClient->getErrors(); + } } diff --git a/src/Mailer/Adapters/SendgridAdapter.php b/src/Mailer/Adapters/SendgridAdapter.php index 073d1850..8201d7a0 100644 --- a/src/Mailer/Adapters/SendgridAdapter.php +++ b/src/Mailer/Adapters/SendgridAdapter.php @@ -38,6 +38,8 @@ class SendgridAdapter implements MailerInterface private string $apiUrl = 'https://api.sendgrid.com/v3/mail/send'; + private HttpClient $httpClient; + /** * @var array */ @@ -57,7 +59,7 @@ public function __construct(array $params) /** * Prepares the data */ - public function prepare(): void + protected function prepare(): void { $this->data['from'] = $this->from; @@ -85,7 +87,7 @@ public function prepare(): void } } - private function sendEmail(): bool + protected function sendEmail(): bool { try { $this->httpClient @@ -103,4 +105,12 @@ private function sendEmail(): bool return false; } } + + /** + * @return array + */ + protected function getTransportErrors(): array + { + return $this->httpClient->getErrors(); + } } diff --git a/src/Mailer/Adapters/SendinblueAdapter.php b/src/Mailer/Adapters/SendinblueAdapter.php index 7e7c1da9..bf0e9368 100644 --- a/src/Mailer/Adapters/SendinblueAdapter.php +++ b/src/Mailer/Adapters/SendinblueAdapter.php @@ -38,6 +38,8 @@ class SendinblueAdapter implements MailerInterface private string $apiUrl = 'https://api.sendinblue.com/v3/smtp/email'; + private HttpClient $httpClient; + /** * @var array */ @@ -57,7 +59,7 @@ public function __construct(array $params) /** * Prepares the data */ - private function prepare(): void + protected function prepare(): void { $this->data['sender'] = $this->from; $this->data['to'] = $this->addresses; @@ -77,7 +79,7 @@ private function prepare(): void } } - private function sendEmail(): bool + protected function sendEmail(): bool { try { $this->httpClient @@ -96,4 +98,12 @@ private function sendEmail(): bool return false; } } + + /** + * @return array + */ + protected function getTransportErrors(): array + { + return $this->httpClient->getErrors(); + } } diff --git a/src/Mailer/Adapters/SmtpAdapter.php b/src/Mailer/Adapters/SmtpAdapter.php index 98648670..e8bd0aca 100644 --- a/src/Mailer/Adapters/SmtpAdapter.php +++ b/src/Mailer/Adapters/SmtpAdapter.php @@ -33,6 +33,8 @@ class SmtpAdapter implements MailerInterface public string $name = 'SMTP'; + private PHPMailer $mailer; + /** * SmtpAdapter constructor * @param array $params @@ -69,7 +71,7 @@ public function setReplay(string $email, ?string $name = null): SmtpAdapter /** * Gets "Reply-To" addresses - * @return array + * @return array */ public function getReplays(): array { @@ -94,7 +96,7 @@ public function setCC(string $email, ?string $name = null): SmtpAdapter /** * Gets "CC" addresses - * @return array + * @return array */ public function getCCs(): array { @@ -119,7 +121,7 @@ public function setBCC(string $email, ?string $name = null): SmtpAdapter /** * Get "BCC" addresses - * @return array + * @return array */ public function getBCCs(): array { @@ -134,13 +136,12 @@ public function getBCCs(): array public function setAttachment(string $attachment): SmtpAdapter { $this->attachments[] = $attachment; - ; return $this; } /** * Gets the attachments - * @return array + * @return array */ public function getAttachments(): array { @@ -165,13 +166,61 @@ public function setStringAttachment(string $content, string $filename): SmtpAdap /** * Gets the string attachments - * @return array + * @return array */ public function getStringAttachments(): array { return $this->stringAttachments; } + /** + * @throws Exception + */ + protected function resolveMessageId(): string + { + preg_match('/<(.*?)@/', preg_quote($this->mailer->getLastMessageID()), $matches); + return $matches[1] ?? bin2hex(random_bytes(16)); + } + + protected function getRenderedMessage(): ?string + { + return $this->mailer->getSentMIMEMessage(); + } + + /** + * @throws \PHPMailer\PHPMailer\Exception + */ + protected function beforeSave(): void + { + $this->mailer->preSend(); + } + + /** + * @return array + */ + protected function getTransportErrors(): array + { + $error = $this->mailer->ErrorInfo; + return $error !== '' ? [$error] : []; + } + + protected function resetTransportState(): void + { + $this->replyToAddresses = []; + $this->ccAddresses = []; + $this->bccAddresses = []; + $this->attachments = []; + $this->stringAttachments = []; + + $this->mailer->clearAddresses(); + $this->mailer->clearCCs(); + $this->mailer->clearBCCs(); + $this->mailer->clearReplyTos(); + $this->mailer->clearAllRecipients(); + $this->mailer->clearAttachments(); + $this->mailer->clearCustomHeaders(); + } + /** * Setups the SMTP * @param array $params @@ -191,7 +240,7 @@ private function setupSmtp(array $params): void * Prepares the data * @throws Exception */ - private function prepare(): void + protected function prepare(): void { $this->mailer->setFrom($this->from['email'], $this->from['name']); @@ -239,7 +288,7 @@ private function fillProperties(string $method, array $fields = []): void /** * @throws \PHPMailer\PHPMailer\Exception */ - private function sendEmail(): bool + protected function sendEmail(): bool { return $this->mailer->send(); } diff --git a/src/Mailer/Contracts/MailerInterface.php b/src/Mailer/Contracts/MailerInterface.php index b25c0c35..46e9aab9 100644 --- a/src/Mailer/Contracts/MailerInterface.php +++ b/src/Mailer/Contracts/MailerInterface.php @@ -40,7 +40,7 @@ public function setAddress(string $email, ?string $name = null): MailerInterface /** * Gets 'To' addresses - * @return array + * @return array */ public function getAddresses(): array; diff --git a/src/Mailer/Factories/MailerFactory.php b/src/Mailer/Factories/MailerFactory.php index 70a43c6b..65a2d293 100644 --- a/src/Mailer/Factories/MailerFactory.php +++ b/src/Mailer/Factories/MailerFactory.php @@ -19,12 +19,13 @@ use Quantum\Mailer\Adapters\SendinblueAdapter; use Quantum\Mailer\Exceptions\MailerException; use Quantum\Config\Exceptions\ConfigException; +use Quantum\Mailer\Contracts\MailerInterface; use Quantum\Mailer\Adapters\MandrillAdapter; use Quantum\Mailer\Adapters\SendgridAdapter; use Quantum\Mailer\Adapters\MailgunAdapter; +use Quantum\Mailer\Adapters\ResendAdapter; use Quantum\App\Exceptions\BaseException; use Quantum\Mailer\Adapters\SmtpAdapter; -use Quantum\Mailer\Adapters\ResendAdapter; use Quantum\Di\Exceptions\DiException; use Quantum\Mailer\Enums\MailerType; use Quantum\Mailer\Mailer; @@ -77,9 +78,18 @@ public static function get(?string $adapter = null): Mailer return self::$instances[$adapter]; } + /** + * @throws MailerException + */ private static function createInstance(string $adapterClass, string $adapter): Mailer { - return new Mailer(new $adapterClass(config()->get('mailer.' . $adapter))); + $adapterInstance = new $adapterClass(config()->get('mailer.' . $adapter)); + + if (!$adapterInstance instanceof MailerInterface) { + throw MailerException::adapterNotSupported($adapter); + } + + return new Mailer($adapterInstance); } /** diff --git a/src/Mailer/Traits/MailerTrait.php b/src/Mailer/Traits/MailerTrait.php index 0f1683e5..6458937e 100644 --- a/src/Mailer/Traits/MailerTrait.php +++ b/src/Mailer/Traits/MailerTrait.php @@ -18,8 +18,6 @@ use Quantum\Mailer\Contracts\MailerInterface; use Quantum\Di\Exceptions\DiException; -use Quantum\HttpClient\HttpClient; -use PHPMailer\PHPMailer\PHPMailer; use Quantum\Debugger\Debugger; use Quantum\Mailer\MailTrap; use ReflectionException; @@ -39,7 +37,7 @@ trait MailerTrait /** * To addresses - * @var array + * @var array */ private array $addresses = []; @@ -61,40 +59,66 @@ trait MailerTrait */ private ?string $templatePath = null; - protected ?HttpClient $httpClient = null; - - protected ?PHPMailer $mailer = null; - /** * Reply To addresses - * @var array + * @var array */ protected array $replyToAddresses = []; /** * CC addresses - * @var array + * @var array */ protected array $ccAddresses = []; /** * BCC addresses - * @var array + * @var array */ protected array $bccAddresses = []; /** * Email attachments - * @var array + * @var array */ protected array $attachments = []; /** * Email attachments created from string - * @var array + * @var array */ protected array $stringAttachments = []; + abstract protected function prepare(): void; + + abstract protected function sendEmail(): bool; + + /** + * @return array + */ + abstract protected function getTransportErrors(): array; + + /** + * @throws Exception + */ + protected function resolveMessageId(): string + { + return bin2hex(random_bytes(16)); + } + + protected function getRenderedMessage(): ?string + { + return null; + } + + protected function beforeSave(): void + { + } + + protected function resetTransportState(): void + { + } + /** * Sets the 'From' email and the name */ @@ -129,7 +153,7 @@ public function setAddress(string $email, ?string $name = null): MailerInterface /** * Gets 'To' addresses - * @return array + * @return array */ public function getAddresses(): array { @@ -204,8 +228,11 @@ public function send(): bool $this->resetFields(); - if ($this->name !== 'SMTP' && !$sent) { - warning(implode(', ', $this->httpClient->getErrors()), ['tab' => Debugger::MAILS]); + if (!$sent) { + $errors = $this->getTransportErrors(); + if (!empty($errors)) { + warning(implode(', ', $errors), ['tab' => Debugger::MAILS]); + } } return $sent; @@ -221,12 +248,7 @@ public function getMessageId(): ?string return self::$messageId; } - if ($this->name == 'SMTP') { - preg_match('/<(.*?)@/', preg_quote($this->mailer->getLastMessageID()), $matches); - self::$messageId = $matches[1] ?? null; - } else { - self::$messageId = bin2hex(random_bytes(16)); - } + self::$messageId = $this->resolveMessageId(); return self::$messageId; } @@ -237,11 +259,14 @@ public function getMessageId(): ?string */ private function saveEmail(): bool { - if ($this->name == 'SMTP') { - $this->mailer->preSend(); + $this->beforeSave(); + + $messageId = $this->getMessageId(); + if ($messageId === null) { + return false; } - return MailTrap::getInstance()->saveMessage($this->getMessageId(), $this->getMessageContent()); + return MailTrap::getInstance()->saveMessage($messageId, $this->getMessageContent()); } /** @@ -250,6 +275,7 @@ private function saveEmail(): bool private function createFromTemplate(): string { ob_start(); + /** @phpstan-ignore argument.type */ ob_implicit_flush(PHP_VERSION_ID >= 80000 ? false : 0); if (is_array($this->message)) { @@ -258,7 +284,8 @@ private function createFromTemplate(): string require $this->templatePath . '.php'; - return ob_get_clean(); + $content = ob_get_clean(); + return $content !== false ? $content : ''; } /** @@ -267,11 +294,7 @@ private function createFromTemplate(): string */ private function getMessageContent(): string { - if ($this->name == 'SMTP') { - return $this->mailer->getSentMIMEMessage(); - } - - return $this->generateMessage(); + return $this->getRenderedMessage() ?? $this->generateMessage(); } /** @@ -300,7 +323,8 @@ private function generateMessage(): string $message .= 'Content-Type: text/html; charset=UTF-8' . PHP_EOL . PHP_EOL; - return $message . ($this->message . PHP_EOL); + $body = is_string($this->message) ? $this->message : ''; + return $message . ($body . PHP_EOL); } /** @@ -314,20 +338,6 @@ private function resetFields(): void $this->message = null; $this->templatePath = null; - if ($this->name == 'SMTP') { - $this->replyToAddresses = []; - $this->ccAddresses = []; - $this->bccAddresses = []; - $this->attachments = []; - $this->stringAttachments = []; - - $this->mailer->clearAddresses(); - $this->mailer->clearCCs(); - $this->mailer->clearBCCs(); - $this->mailer->clearReplyTos(); - $this->mailer->clearAllRecipients(); - $this->mailer->clearAttachments(); - $this->mailer->clearCustomHeaders(); - } + $this->resetTransportState(); } } From 502da2f4b62f5da3459e2c111472dac04e5bffbb Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:34:46 +0400 Subject: [PATCH 21/39] Enforce PHPStan level 7 compliance for Migration package: make TableFactory non-nullable and fail loudly on invalid migration classes --- src/Migration/MigrationManager.php | 21 ++++++++++++++++----- src/Migration/MigrationTable.php | 4 ++-- src/Migration/QtMigration.php | 6 ++---- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/Migration/MigrationManager.php b/src/Migration/MigrationManager.php index e316dc01..92efab3a 100644 --- a/src/Migration/MigrationManager.php +++ b/src/Migration/MigrationManager.php @@ -29,6 +29,7 @@ use Quantum\Storage\FileSystem; use Quantum\Database\Database; use ReflectionException; +use RuntimeException; /** * Class MigrationManager @@ -115,15 +116,14 @@ public function generateMigration(string $table, string $action): string /** * Applies migrations * @throws BaseException - * @throws ConfigException * @throws DatabaseException - * @throws DiException * @throws LangException * @throws MigrationException */ public function applyMigrations(string $direction, ?int $step = null): ?int { - $databaseDriver = $this->db->getConfigs()['driver']; + $configs = $this->db->getConfigs(); + $databaseDriver = $configs['driver'] ?? ''; if (!in_array($databaseDriver, self::DRIVERS)) { throw MigrationException::driverNotSupported($databaseDriver); @@ -146,7 +146,6 @@ public function applyMigrations(string $direction, ?int $step = null): ?int /** * Runs up migrations * @throws DatabaseException - * @throws LangException * @throws MigrationException */ private function upgrade(): int @@ -155,10 +154,13 @@ private function upgrade(): int $migrationTable = new MigrationTable(); $migrationTable->up($this->tableFactory); } + $this->prepareUpMigrations(); + if (empty($this->migrations)) { throw MigrationException::nothingToMigrate(); } + $migratedEntries = []; foreach ($this->migrations as $migrationFile) { $this->fs->require($migrationFile, true); @@ -167,6 +169,10 @@ private function upgrade(): int $migration = new $migrationClassName(); + if (!$migration instanceof QtMigration) { + throw new RuntimeException("Migration class $migrationClassName must extend QtMigration."); + } + $migration->up($this->tableFactory); $migratedEntries[] = $migrationClassName; @@ -178,7 +184,6 @@ private function upgrade(): int /** * Runs down migrations * @throws DatabaseException - * @throws LangException * @throws MigrationException */ private function downgrade(?int $step): int @@ -202,6 +207,10 @@ private function downgrade(?int $step): int $migration = new $migrationClassName(); + if (!$migration instanceof QtMigration) { + throw new RuntimeException("Migration class $migrationClassName must extend QtMigration."); + } + $migration->down($this->tableFactory); $migratedEntries[] = $migrationClassName; @@ -222,9 +231,11 @@ private function prepareUpMigrations(): void { $migratedEntries = $this->getMigratedEntries(); $migrationFiles = $this->getMigrationFiles(); + if ($migratedEntries === [] && $migrationFiles === []) { throw MigrationException::nothingToMigrate(); } + foreach ($migrationFiles as $timestamp => $migrationFile) { foreach ($migratedEntries as $migratedEntry) { if (pathinfo($migrationFile, PATHINFO_FILENAME) == $migratedEntry['migration']) { diff --git a/src/Migration/MigrationTable.php b/src/Migration/MigrationTable.php index d225d5e6..edefeedc 100644 --- a/src/Migration/MigrationTable.php +++ b/src/Migration/MigrationTable.php @@ -35,7 +35,7 @@ class MigrationTable extends QtMigration * Creates the migrations table * @throws DatabaseException */ - public function up(?TableFactory $tableFactory): void + public function up(TableFactory $tableFactory): void { $table = $tableFactory->create(self::TABLE); $table->addColumn('id', Type::INT, 11)->autoIncrement(); @@ -47,7 +47,7 @@ public function up(?TableFactory $tableFactory): void * Drops the migrations table * @throws DatabaseException */ - public function down(?TableFactory $tableFactory): void + public function down(TableFactory $tableFactory): void { $tableFactory->drop(self::TABLE); } diff --git a/src/Migration/QtMigration.php b/src/Migration/QtMigration.php index 5f1e7564..1bcea4e2 100644 --- a/src/Migration/QtMigration.php +++ b/src/Migration/QtMigration.php @@ -26,13 +26,11 @@ abstract class QtMigration { /** * Upgrades with the specified migration class - * @return void */ - abstract public function up(?TableFactory $tableFactory): void; + abstract public function up(TableFactory $tableFactory): void; /** * Downgrades with the specified migration class - * @return void */ - abstract public function down(?TableFactory $tableFactory): void; + abstract public function down(TableFactory $tableFactory): void; } From da94a88b5ac33765053a2394ce2004329fe8417d Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:37:53 +0400 Subject: [PATCH 22/39] Enforce PHPStan level 7 compliance for Model package --- src/Model/DbModel.php | 12 ++++++++---- src/Model/Factories/ModelFactory.php | 11 ++++++++++- src/Model/ModelCollection.php | 5 ++++- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/Model/DbModel.php b/src/Model/DbModel.php index 280cb15f..468d6fe2 100644 --- a/src/Model/DbModel.php +++ b/src/Model/DbModel.php @@ -130,10 +130,14 @@ public function first(): ?DbModel */ public function get(): ModelCollection { - $models = array_map( - fn (?DbalInterface $item): ?DbModel => wrapToModel($item, static::class), - $this->getOrmInstance()->get() - ); + $models = []; + + foreach ($this->getOrmInstance()->get() as $item) { + $model = wrapToModel($item, static::class); + if ($model !== null) { + $models[] = $model; + } + } /** @var ModelCollection $collection */ $collection = new ModelCollection($models); diff --git a/src/Model/Factories/ModelFactory.php b/src/Model/Factories/ModelFactory.php index 1924fab5..b0f75189 100644 --- a/src/Model/Factories/ModelFactory.php +++ b/src/Model/Factories/ModelFactory.php @@ -30,6 +30,9 @@ class ModelFactory { /** * Gets the Model + * @template T of Model + * @param class-string $modelClass + * @return T * @throws ModelException */ public static function get(string $modelClass): Model @@ -99,12 +102,18 @@ protected static function createOrmInstance( ): DbalInterface { $ormClass = Database::getInstance()->getOrmClass(); - return new $ormClass( + $instance = new $ormClass( $table, $modelName, $idColumn, $foreignKeys, $hidden ); + + if (!$instance instanceof DbalInterface) { + throw ModelException::notInstanceOf($ormClass, DbalInterface::class); + } + + return $instance; } } diff --git a/src/Model/ModelCollection.php b/src/Model/ModelCollection.php index de755e9f..459f0aca 100644 --- a/src/Model/ModelCollection.php +++ b/src/Model/ModelCollection.php @@ -195,7 +195,10 @@ private function processModels(): void private function validateModel($model): void { if (!$model instanceof Model) { - throw ModelException::notInstanceOf(get_class($model), Model::class); + throw ModelException::notInstanceOf( + is_object($model) ? get_class($model) : gettype($model), + Model::class + ); } } } From c161145662b4a8dc57d3ddb0cc79e465e1c46d2b Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:39:16 +0400 Subject: [PATCH 23/39] Enforce PHPStan level 7 compliance for Module package --- src/Module/ModuleManager.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Module/ModuleManager.php b/src/Module/ModuleManager.php index 2c002a8b..f3ce8f6d 100644 --- a/src/Module/ModuleManager.php +++ b/src/Module/ModuleManager.php @@ -158,6 +158,10 @@ private function copyDirectory(string $src, string $dst, bool $processTemplates, $dir = $this->fs->listDirectory($src); + if (!is_array($dir)) { + return $copiedFiles; + } + foreach ($dir as $file) { $srcPath = $file; $dstPath = str_replace($src, $dst, $file); @@ -181,6 +185,11 @@ private function processTemplates(string $srcPath, string &$dstPath): void { $dstPath = str_replace('.php.tpl', '.php', $dstPath); $content = $this->fs->get($srcPath); + + if (!is_string($content)) { + return; + } + $processedContent = $this->replacePlaceholders($content); $this->fs->put($dstPath, $processedContent); } From 8d0504d3607c1ef74f334e3408b7c7aec4cc442b Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:40:53 +0400 Subject: [PATCH 24/39] Enforce PHPStan level 7 compliance for Paginator package --- src/Paginator/Traits/PaginatorTrait.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Paginator/Traits/PaginatorTrait.php b/src/Paginator/Traits/PaginatorTrait.php index f3264f74..00dd0c0b 100644 --- a/src/Paginator/Traits/PaginatorTrait.php +++ b/src/Paginator/Traits/PaginatorTrait.php @@ -220,7 +220,7 @@ protected function getUri(bool $withBaseUrl = false): string $url = $this->baseUrl . $routeUrl; } - $delimiter = strpos($url, '?') ? '&' : '?'; + $delimiter = strpos($url ?? '', '?') !== false ? '&' : '?'; return $url . $delimiter; } @@ -275,7 +275,7 @@ protected function getPreviousPageItem(?string $previousPageLink): string /** * Get items links HTML - * @param array $links + * @param array $links */ protected function getItemsLinks(int $startPage, int $endPage, int $currentPage, array $links): string { @@ -320,7 +320,7 @@ protected function addFirstPageLink(int $startPage): string /** * Add last page link HTML - * @param array $links + * @param array $links */ protected function addLastPageLink(int $endPage, int $totalPages, array $links): string { From 697538e1b6a50fc594cd144a761e692e2ba14a17 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:42:35 +0400 Subject: [PATCH 25/39] Enforce PHPStan level 7 compliance for Renderer package --- src/Renderer/Adapters/HtmlAdapter.php | 5 ++++- src/Renderer/Factories/RendererFactory.php | 12 +++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/Renderer/Adapters/HtmlAdapter.php b/src/Renderer/Adapters/HtmlAdapter.php index eede5c46..1b1082c1 100644 --- a/src/Renderer/Adapters/HtmlAdapter.php +++ b/src/Renderer/Adapters/HtmlAdapter.php @@ -66,6 +66,7 @@ public function render(string $view, array $params = []): string } ob_start(); + /** @phpstan-ignore argument.type */ ob_implicit_flush(PHP_VERSION_ID >= 80000 ? false : 0); if ($params !== []) { @@ -74,7 +75,9 @@ public function render(string $view, array $params = []): string require $filePath; - return ob_get_clean(); + $content = ob_get_clean(); + + return $content !== false ? $content : ''; } /** diff --git a/src/Renderer/Factories/RendererFactory.php b/src/Renderer/Factories/RendererFactory.php index 17a02bb4..4112f888 100644 --- a/src/Renderer/Factories/RendererFactory.php +++ b/src/Renderer/Factories/RendererFactory.php @@ -16,6 +16,7 @@ namespace Quantum\Renderer\Factories; +use Quantum\Renderer\Contracts\TemplateRendererInterface; use Quantum\Renderer\Exceptions\RendererException; use Quantum\Config\Exceptions\ConfigException; use Quantum\Renderer\Adapters\HtmlAdapter; @@ -69,9 +70,18 @@ public static function get(?string $adapter = null): Renderer return self::$instances[$adapter]; } + /** + * @throws RendererException + */ private static function createInstance(string $adapterClass, string $adapter): Renderer { - return new Renderer(new $adapterClass(config()->get('view.' . $adapter))); + $adapterInstance = new $adapterClass(config()->get('view.' . $adapter)); + + if (!$adapterInstance instanceof TemplateRendererInterface) { + throw RendererException::adapterNotSupported($adapter); + } + + return new Renderer($adapterInstance); } /** From 7187bc9753a26498514aa1749a280f2144b4a198 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:44:28 +0400 Subject: [PATCH 26/39] Enforce PHPStan level 7 compliance for ResourceCache package --- src/ResourceCache/ViewCache.php | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/ResourceCache/ViewCache.php b/src/ResourceCache/ViewCache.php index 280dc14b..35fe7551 100644 --- a/src/ResourceCache/ViewCache.php +++ b/src/ResourceCache/ViewCache.php @@ -103,8 +103,11 @@ public function setup(): void public function serveCachedView(string $uri, Response $response): bool { if ($this->isEnabled() && $this->exists($uri)) { - $response->html($this->get($uri)); - return true; + $cachedContent = $this->get($uri); + if ($cachedContent !== null) { + $response->html($cachedContent); + return true; + } } return false; @@ -141,7 +144,9 @@ public function get(string $key): ?string return null; } - return $this->fs->get($this->getCacheFile($key)); + $content = $this->fs->get($this->getCacheFile($key)); + + return is_string($content) ? $content : null; } /** From 10110e10db1a51d42f789ea0d6982530d0893668 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:43:55 +0400 Subject: [PATCH 27/39] Enforce PHPStan level 7 compliance for Router package --- src/Router/Helpers/router.php | 6 +++--- src/Router/PatternCompiler.php | 8 ++++---- src/Router/Route.php | 6 +++--- src/Router/RouteBuilder.php | 2 +- src/Router/RouteDispatcher.php | 2 ++ src/Router/RouteFinder.php | 4 ++-- 6 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/Router/Helpers/router.php b/src/Router/Helpers/router.php index 1cfebb4b..d2261c2a 100644 --- a/src/Router/Helpers/router.php +++ b/src/Router/Helpers/router.php @@ -21,7 +21,7 @@ /** * Gets current route middlewares - * @return array|null + * @return array|null * @throws DiException|ReflectionException */ function current_middlewares(): ?array @@ -107,7 +107,7 @@ function route_pattern(): string $request = Di::get(Request::class); $matchedRoute = $request->getMatchedRoute(); - return $matchedRoute ? $matchedRoute->getRoute()->getCompiledPattern() : ''; + return $matchedRoute ? ($matchedRoute->getRoute()->getCompiledPattern() ?? '') : ''; } /** @@ -143,7 +143,7 @@ function route_param(string $name) function route_method(): string { $request = Di::get(Request::class); - return $request->getMethod(); + return $request->getMethod() ?? ''; } /** diff --git a/src/Router/PatternCompiler.php b/src/Router/PatternCompiler.php index 5404429d..a7ff548b 100644 --- a/src/Router/PatternCompiler.php +++ b/src/Router/PatternCompiler.php @@ -49,7 +49,7 @@ public function match(Route $route, string $uri): bool { [$pattern, $segmentParams] = $this->compile($route); - $requestUri = urldecode(parse_url($uri, PHP_URL_PATH) ?? ''); + $requestUri = urldecode(parse_url($uri, PHP_URL_PATH) ?: ''); if (!preg_match('/^' . $this->escape($pattern) . '$/u', $requestUri, $matches)) { $this->params = []; @@ -115,7 +115,7 @@ public function getParams(): array * Build the final named parameter map from regex matches and param metadata. * * @param array $matches PCRE match array from preg_match (named captures) - * @param array $segmentParams + * @param list> $segmentParams * @return array */ protected function extractParams(array $matches, array $segmentParams): array @@ -156,7 +156,7 @@ protected function getSegmentParam(string $segment, int $index, int $lastIndex): /** * Generate the regex pattern and name for a matched parameter segment. * @param array $match - * @return array + * @return array{name: string, pattern: string} * @throws RouteException */ protected function getParamPattern(array $match, string $expr, int $index, int $lastIndex): array @@ -212,7 +212,7 @@ protected function getParamName(array $match, int $index): string * @param array|list> $params * @throws RouteException */ - protected function checkParamName($params, string $name): void + protected function checkParamName(array $params, string $name): void { foreach ($params as $param) { if ($param['name'] === $name) { diff --git a/src/Router/Route.php b/src/Router/Route.php index 7be833f1..feff8681 100644 --- a/src/Router/Route.php +++ b/src/Router/Route.php @@ -50,7 +50,7 @@ final class Route protected ?string $module = null; /** - * @var array|null + * @var array|null */ protected ?array $middlewares = null; @@ -187,7 +187,7 @@ public function getGroup(): ?string */ public function prefix(?string $prefix): self { - $this->prefix = $prefix !== '' ? trim($prefix, '/') : null; + $this->prefix = ($prefix !== null && $prefix !== '') ? trim($prefix, '/') : null; return $this; } @@ -300,7 +300,7 @@ public function getName(): ?string /** * Return middleware list. - * @return array|null + * @return array|null */ public function getMiddlewares(): ?array { diff --git a/src/Router/RouteBuilder.php b/src/Router/RouteBuilder.php index 5f618912..464ae1ee 100644 --- a/src/Router/RouteBuilder.php +++ b/src/Router/RouteBuilder.php @@ -41,7 +41,7 @@ final class RouteBuilder private ?string $currentGroupName = null; /** - * @var array + * @var array */ private array $groupRoutes = []; diff --git a/src/Router/RouteDispatcher.php b/src/Router/RouteDispatcher.php index 0146a7f5..45038e54 100644 --- a/src/Router/RouteDispatcher.php +++ b/src/Router/RouteDispatcher.php @@ -57,6 +57,7 @@ public function dispatch(MatchedRoute $matched, Request $request): void $this->callHook($controller, '__before', $params); + /** @phpstan-ignore argument.type */ $this->invoke($callable, $params); $this->callHook($controller, '__after', $params); @@ -106,6 +107,7 @@ private function invoke(callable $callable, array $params): void private function callHook(object $controller, string $hook, array $params): void { if (method_exists($controller, $hook)) { + /** @phpstan-ignore argument.type */ $this->invoke([$controller, $hook], $params); } } diff --git a/src/Router/RouteFinder.php b/src/Router/RouteFinder.php index 8bab1ef6..b7482ca3 100644 --- a/src/Router/RouteFinder.php +++ b/src/Router/RouteFinder.php @@ -41,8 +41,8 @@ public function __construct(RouteCollection $routes) */ public function find(Request $request): ?MatchedRoute { - $method = $request->getMethod(); - $uri = $request->getUri(); + $method = $request->getMethod() ?? ''; + $uri = $request->getUri() ?? ''; foreach ($this->routes->all() as $route) { if (!$route->allowsMethod($method)) { From d45207340dba7114e6faf2de7f16ac73b9ef3503 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:45:43 +0400 Subject: [PATCH 28/39] Enforce PHPStan level 7 compliance for Session package --- src/Session/Factories/SessionFactory.php | 9 ++++++++- src/Session/Traits/SessionTrait.php | 4 +++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Session/Factories/SessionFactory.php b/src/Session/Factories/SessionFactory.php index 39280f2e..8a104cf8 100644 --- a/src/Session/Factories/SessionFactory.php +++ b/src/Session/Factories/SessionFactory.php @@ -18,6 +18,7 @@ use Quantum\Session\Adapters\Database\DatabaseSessionAdapter; use Quantum\Session\Adapters\Native\NativeSessionAdapter; +use Quantum\Session\Contracts\SessionStorageInterface; use Quantum\Session\Exceptions\SessionException; use Quantum\Config\Exceptions\ConfigException; use Quantum\App\Exceptions\BaseException; @@ -71,7 +72,13 @@ public static function get(?string $adapter = null): Session private static function createInstance(string $adapterClass, string $adapter): Session { - return new Session(new $adapterClass(config()->get('session.' . $adapter))); + $adapterInstance = new $adapterClass(config()->get('session.' . $adapter)); + + if (!$adapterInstance instanceof SessionStorageInterface) { + throw SessionException::adapterNotSupported($adapter); + } + + return new Session($adapterInstance); } /** diff --git a/src/Session/Traits/SessionTrait.php b/src/Session/Traits/SessionTrait.php index 3fa3894c..789e7933 100644 --- a/src/Session/Traits/SessionTrait.php +++ b/src/Session/Traits/SessionTrait.php @@ -113,7 +113,9 @@ public function flush(): void */ public function getId(): ?string { - return session_id(); + $id = session_id(); + + return $id !== false ? $id : null; } /** From 1a76966ca6f1202be0f8d5ea2ebda7f0be05a789 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:25:16 +0400 Subject: [PATCH 29/39] Enforce PHPStan level 7 compliance for Storage package --- src/Storage/Adapters/Dropbox/DropboxApp.php | 2 +- .../Adapters/Local/LocalFileSystemAdapter.php | 6 +++- src/Storage/Factories/FileSystemFactory.php | 13 ++++++--- src/Storage/Traits/CloudAppTrait.php | 7 +++-- src/Storage/UploadedFile.php | 29 +++++++++++++------ 5 files changed, 40 insertions(+), 17 deletions(-) diff --git a/src/Storage/Adapters/Dropbox/DropboxApp.php b/src/Storage/Adapters/Dropbox/DropboxApp.php index baecdea2..64a6f81a 100644 --- a/src/Storage/Adapters/Dropbox/DropboxApp.php +++ b/src/Storage/Adapters/Dropbox/DropboxApp.php @@ -219,7 +219,7 @@ public function contentRequest(string $endpoint, array $params, string $content { $headers = [ 'Authorization' => 'Bearer ' . $this->tokenService->getAccessToken(), - 'Dropbox-API-Arg' => json_encode($params), + 'Dropbox-API-Arg' => json_encode($params) ?: '', 'Content-Type' => 'application/octet-stream', ]; diff --git a/src/Storage/Adapters/Local/LocalFileSystemAdapter.php b/src/Storage/Adapters/Local/LocalFileSystemAdapter.php index b0ed88f1..15fb72bb 100644 --- a/src/Storage/Adapters/Local/LocalFileSystemAdapter.php +++ b/src/Storage/Adapters/Local/LocalFileSystemAdapter.php @@ -160,7 +160,11 @@ public function listDirectory(string $dirname) try { foreach (scandir($dirname) as $item) { if ($item !== '.' && $item !== '..') { - $entries[] = realpath($dirname . DS . $item); + $resolved = realpath($dirname . DS . $item); + + if ($resolved !== false) { + $entries[] = $resolved; + } } } diff --git a/src/Storage/Factories/FileSystemFactory.php b/src/Storage/Factories/FileSystemFactory.php index 270a77f7..aff8bcbc 100644 --- a/src/Storage/Factories/FileSystemFactory.php +++ b/src/Storage/Factories/FileSystemFactory.php @@ -19,6 +19,7 @@ use Quantum\Storage\Adapters\GoogleDrive\GoogleDriveFileSystemAdapter; use Quantum\Storage\Adapters\Dropbox\DropboxFileSystemAdapter; use Quantum\Storage\Adapters\Local\LocalFileSystemAdapter; +use Quantum\Storage\Contracts\FilesystemAdapterInterface; use Quantum\Storage\Adapters\GoogleDrive\GoogleDriveApp; use Quantum\Storage\Contracts\TokenServiceInterface; use Quantum\Storage\Exceptions\FileSystemException; @@ -59,7 +60,7 @@ class FileSystemFactory ]; /** - * @var array + * @var array */ private static array $instances = []; @@ -94,9 +95,13 @@ public static function get(?string $adapter = null): FileSystem */ private static function createInstance(string $adapterClass, string $adapter): FileSystem { - return new FileSystem(new $adapterClass( - self::createCloudApp($adapter) - )); + $fsAdapter = new $adapterClass(self::createCloudApp($adapter)); + + if (!$fsAdapter instanceof FilesystemAdapterInterface) { + throw FileSystemException::adapterNotSupported($adapter); + } + + return new FileSystem($fsAdapter); } /** diff --git a/src/Storage/Traits/CloudAppTrait.php b/src/Storage/Traits/CloudAppTrait.php index 6eb77890..0ab11dfc 100644 --- a/src/Storage/Traits/CloudAppTrait.php +++ b/src/Storage/Traits/CloudAppTrait.php @@ -60,10 +60,13 @@ public function sendRequest(string $uri, $data = null, array $headers = [], stri $prevHeaders['Authorization'] = 'Bearer ' . $accessToken; - $responseBody = $this->sendRequest($prevUrl, $prevData, $prevHeaders); + $responseBody = $this->sendRequest($prevUrl ?? '', $prevData, $prevHeaders); } else { - throw new Exception(json_encode($responseBody ?? $errors), E_ERROR); + throw new Exception( + json_encode($responseBody ?? $errors) ?: '', + E_ERROR + ); } } diff --git a/src/Storage/UploadedFile.php b/src/Storage/UploadedFile.php index 86e8402b..b112073b 100644 --- a/src/Storage/UploadedFile.php +++ b/src/Storage/UploadedFile.php @@ -30,6 +30,7 @@ use Quantum\Loader\Setup; use ReflectionException; use Gumlet\ImageResize; +use RuntimeException; use Quantum\Di\Di; use SplFileInfo; use finfo; @@ -156,7 +157,7 @@ public function setAllowedMimeTypes(array $allowedMimeTypes, bool $merge = true) public function getName(): string { if (!$this->name) { - $this->name = $this->localFileSystem->fileName($this->originalName); + $this->name = $this->localFileSystem->fileName($this->originalName ?? ''); } return $this->name; @@ -194,7 +195,7 @@ public function getRemoteFileSystem(): ?FilesystemAdapterInterface public function getExtension(): string { if (!$this->extension) { - $this->extension = strtolower($this->localFileSystem->extension($this->originalName)); + $this->extension = strtolower($this->localFileSystem->extension($this->originalName ?? '')); } return $this->extension; @@ -216,8 +217,8 @@ public function getMimeType(): string if (!$this->mimetype) { $fileInfo = new finfo(FILEINFO_MIME); $mimetype = $fileInfo->file($this->getPathname()); - $mimetypeParts = preg_split('/\s*[;,]\s*/', $mimetype); - $this->mimetype = strtolower($mimetypeParts[0]); + $mimetypeParts = is_string($mimetype) ? preg_split('/\s*[;,]\s*/', $mimetype) : false; + $this->mimetype = strtolower(is_array($mimetypeParts) ? $mimetypeParts[0] : ''); unset($fileInfo); } @@ -229,7 +230,7 @@ public function getMimeType(): string */ public function getMd5(): string { - return md5_file($this->getPathname()); + return md5_file($this->getPathname()) ?: ''; } /** @@ -243,11 +244,15 @@ public function getDimensions(): array throw FileUploadException::fileTypeNotAllowed($this->getExtension()); } - [$width, $height] = getimagesize($this->getPathname()); + $size = getimagesize($this->getPathname()); + + if ($size === false) { + throw FileUploadException::fileTypeNotAllowed($this->getExtension()); + } return [ - 'width' => $width, - 'height' => $height, + 'width' => $size[0], + 'height' => $size[1], ]; } @@ -432,7 +437,13 @@ protected function setAllowedMimeTypesMap(array $allowedMimeTypes, bool $merge = protected function applyModifications(string $filePath) { $image = new ImageResize($filePath); - call_user_func_array([$image, $this->imageModifierFuncName], array_values($this->params)); + $callable = [$image, $this->imageModifierFuncName ?? '']; + + if (!is_callable($callable)) { + throw new RuntimeException('Invalid image modifier: ' . ($this->imageModifierFuncName ?? 'null')); + } + + call_user_func_array($callable, array_values($this->params)); $image->save($filePath); } From a39156f59daed927831d5f587a42c67b9e98b51b Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:28:27 +0400 Subject: [PATCH 30/39] Enforce PHPStan level 7 compliance for Tracer package --- src/Tracer/ErrorHandler.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Tracer/ErrorHandler.php b/src/Tracer/ErrorHandler.php index a5e9b506..0e29de98 100644 --- a/src/Tracer/ErrorHandler.php +++ b/src/Tracer/ErrorHandler.php @@ -157,6 +157,10 @@ private function handleWebException(Throwable $throwable): void private function logError(Throwable $e, string $errorType): void { + if ($this->logger === null) { + return; + } + $logMethod = method_exists($this->logger, $errorType) ? $errorType : 'error'; $this->logger->$logMethod($e->getMessage(), ['trace' => $e->getTraceAsString()]); @@ -204,7 +208,8 @@ private function getSourceCode(string $filename, int $lineNumber, string $classN { $lineNumber--; - $start = max($lineNumber - floor(self::NUM_LINES / 2), 1); + $halfLines = intdiv(self::NUM_LINES, 2); + $start = max($lineNumber - $halfLines, 1); $adapter = fs()->getAdapter(); From 4dfad6ac777dec17f8160e31d2fa7d25581e5909 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:32:18 +0400 Subject: [PATCH 31/39] Enforce PHPStan level 7 compliance for Validator package --- src/Validation/Traits/General.php | 20 +++++++++++++------- src/Validation/Traits/Resource.php | 3 ++- src/Validation/Validator.php | 4 ++-- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/Validation/Traits/General.php b/src/Validation/Traits/General.php index d7eec2dd..41d59e9c 100644 --- a/src/Validation/Traits/General.php +++ b/src/Validation/Traits/General.php @@ -57,7 +57,7 @@ protected function email($value): bool protected function creditCard($value): bool { $value = (string) $value; - $number = preg_replace('/\D/', '', $value); + $number = preg_replace('/\D/', '', $value) ?? ''; $length = function_exists('mb_strlen') ? mb_strlen($number) : strlen($number); if ($length == 0) { @@ -153,8 +153,14 @@ protected function date($value, ?string $format = null): bool { $value = (string) $value; if (!$format) { - $cdate1 = date('Y-m-d', strtotime($value)); - $cdate2 = date('Y-m-d H:i:s', strtotime($value)); + $timestamp = strtotime($value); + + if ($timestamp === false) { + return false; + } + + $cdate1 = date('Y-m-d', $timestamp); + $cdate2 = date('Y-m-d H:i:s', $timestamp); return $cdate1 === $value || $cdate2 === $value; } @@ -211,11 +217,11 @@ protected function same($value, string $otherField): bool protected function unique($value, string $className, string $columnName): bool { /** @var DbModel $model */ - $model = ModelFactory::get(ucfirst($className)); + $model = ModelFactory::get(ucfirst($className)); /** @phpstan-ignore argument.type, argument.templateType */ $record = $model->findOneBy($columnName, $value); - return $record->isEmpty(); + return $record === null || $record->isEmpty(); } /** @@ -227,11 +233,11 @@ protected function unique($value, string $className, string $columnName): bool protected function exists($value, string $className, string $columnName): bool { /** @var DbModel $model */ - $model = ModelFactory::get(ucfirst($className)); + $model = ModelFactory::get(ucfirst($className)); /** @phpstan-ignore argument.type, argument.templateType */ $record = $model->findOneBy($columnName, $value); - return !$record->isEmpty(); + return $record !== null && !$record->isEmpty(); } /** diff --git a/src/Validation/Traits/Resource.php b/src/Validation/Traits/Resource.php index 05caae17..19f50017 100644 --- a/src/Validation/Traits/Resource.php +++ b/src/Validation/Traits/Resource.php @@ -46,7 +46,8 @@ protected function urlExists(string $value): bool } if (function_exists('checkdnsrr') && function_exists('idn_to_ascii')) { - return checkdnsrr(idn_to_ascii($host, 0, INTL_IDNA_VARIANT_UTS46), 'A'); + $ascii = idn_to_ascii($host, 0, INTL_IDNA_VARIANT_UTS46); + return $ascii !== false && checkdnsrr($ascii, 'A'); } else { return gethostbyname($host) !== $host; } diff --git a/src/Validation/Validator.php b/src/Validation/Validator.php index a458405e..94df7b62 100644 --- a/src/Validation/Validator.php +++ b/src/Validation/Validator.php @@ -74,7 +74,7 @@ class Validator public function setRule(string $field, array $rules): void { foreach ($rules as $rule) { - $ruleName = key($rule); + $ruleName = (string) key($rule); $ruleParam = current($rule); $this->setOrUpdateRule($field, $ruleName, $ruleParam); } @@ -97,7 +97,7 @@ public function setRules(array $rules): void */ public function updateRule(string $field, array $rule): void { - $ruleName = key($rule); + $ruleName = (string) key($rule); $ruleParam = current($rule); $this->setOrUpdateRule($field, $ruleName, $ruleParam); } From 513ab7fce04f83bcccee08c12fd5dcfd411f229b Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:37:52 +0400 Subject: [PATCH 32/39] Enforce PHPStan level 7 compliance for View package --- src/View/QtView.php | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/View/QtView.php b/src/View/QtView.php index c787cb13..03062677 100644 --- a/src/View/QtView.php +++ b/src/View/QtView.php @@ -173,7 +173,9 @@ public function flushParams(): void */ public function render(string $viewFile, array $params = []): ?string { - if (!$this->layoutFile) { + $layoutFile = $this->layoutFile; + + if (!$layoutFile) { throw ViewException::noLayoutSet(); } @@ -191,12 +193,15 @@ public function render(string $viewFile, array $params = []): ?string $this->updateDebugger($viewFile); } - $layoutContent = $this->renderFile($this->layoutFile); + $layoutContent = $this->renderFile($layoutFile); if ($this->viewCache->isEnabled()) { - $layoutContent = $this->viewCache - ->set(route_uri(), $layoutContent) - ->get(route_uri()); + $uri = route_uri(); + if ($uri !== null) { + $layoutContent = $this->viewCache + ->set($uri, $layoutContent) + ->get($uri); + } } return $layoutContent; @@ -277,6 +282,10 @@ private function sanitizeHtml(string $value): string return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } + /** + * @throws ReflectionException + * @throws DiException + */ private function updateDebugger(string $viewFile): void { $routesCell = $this->debugger->getStoreCell(Debugger::ROUTES); From cd7a406ac78afae6e7e031e4f1e45b3d11417d4a Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:43:48 +0400 Subject: [PATCH 33/39] The PHPStan configuration was updated to increase the analysis level from 6 to 7. --- phpstan.neon.dist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 159c9b14..706c07c3 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,6 +1,6 @@ parameters: phpVersion: 70400 - level: 6 + level: 7 paths: - src From cb04108a0c62b5604ee431a69d8a0b8365c6bc90 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:46:16 +0400 Subject: [PATCH 34/39] Adding unit tests for commands --- .../Console/Commands/DebugBarCommandTest.php | 53 ++++++++++++++++ .../Unit/Console/Commands/EnvCommandTest.php | 48 ++++++++++++++ .../Commands/InstallToolkitCommandTest.php | 35 +++++++++++ .../Commands/KeyGenerateCommandTest.php | 60 ++++++++++++++++++ .../Commands/MigrationGenerateCommandTest.php | 34 ++++++++++ .../Commands/MigrationMigrateCommandTest.php | 34 ++++++++++ .../Commands/ModuleGenerateCommandTest.php | 37 +++++++++++ .../Console/Commands/OpenApiCommandTest.php | 40 ++++++++++++ .../ResourceCacheClearCommandTest.php | 62 +++++++++++++++++++ .../Console/Commands/RouteListCommandTest.php | 32 ++++++++++ .../Console/Commands/ServeCommandTest.php | 49 +++++++++++++++ .../Console/Commands/VersionCommandTest.php | 25 ++++++++ 12 files changed, 509 insertions(+) create mode 100644 tests/Unit/Console/Commands/DebugBarCommandTest.php create mode 100644 tests/Unit/Console/Commands/EnvCommandTest.php create mode 100644 tests/Unit/Console/Commands/InstallToolkitCommandTest.php create mode 100644 tests/Unit/Console/Commands/KeyGenerateCommandTest.php create mode 100644 tests/Unit/Console/Commands/MigrationGenerateCommandTest.php create mode 100644 tests/Unit/Console/Commands/MigrationMigrateCommandTest.php create mode 100644 tests/Unit/Console/Commands/ModuleGenerateCommandTest.php create mode 100644 tests/Unit/Console/Commands/OpenApiCommandTest.php create mode 100644 tests/Unit/Console/Commands/ResourceCacheClearCommandTest.php create mode 100644 tests/Unit/Console/Commands/RouteListCommandTest.php create mode 100644 tests/Unit/Console/Commands/ServeCommandTest.php create mode 100644 tests/Unit/Console/Commands/VersionCommandTest.php diff --git a/tests/Unit/Console/Commands/DebugBarCommandTest.php b/tests/Unit/Console/Commands/DebugBarCommandTest.php new file mode 100644 index 00000000..44f7a28e --- /dev/null +++ b/tests/Unit/Console/Commands/DebugBarCommandTest.php @@ -0,0 +1,53 @@ +command = new DebugBarCommand(); + $this->tester = new CommandTester($this->command); + } + + public function testCommandMetadata(): void + { + $this->assertSame('install:debugbar', $this->command->getName()); + $this->assertSame('Publishes debugbar assets', $this->command->getDescription()); + $this->assertSame('The command will publish debugbar assets', $this->command->getHelp()); + } + + public function testConstructorInitializesFileSystem(): void + { + $fs = $this->getPrivateProperty($this->command, 'fs'); + $this->assertInstanceOf(FileSystem::class, $fs); + } + + public function testExecShowsErrorWhenAlreadyInstalled(): void + { + $assetsPath = assets_dir() . DS . 'DebugBar' . DS . 'Resources'; + + mkdir($assetsPath, 0777, true); + file_put_contents($assetsPath . DS . 'debugbar.css', '/* stub */'); + + $this->tester->execute([]); + + $output = $this->tester->getDisplay(); + $this->assertStringContainsString('already installed', $output); + + @unlink($assetsPath . DS . 'debugbar.css'); + @rmdir($assetsPath); + @rmdir(assets_dir() . DS . 'DebugBar'); + } +} diff --git a/tests/Unit/Console/Commands/EnvCommandTest.php b/tests/Unit/Console/Commands/EnvCommandTest.php new file mode 100644 index 00000000..f4975d05 --- /dev/null +++ b/tests/Unit/Console/Commands/EnvCommandTest.php @@ -0,0 +1,48 @@ +command = new EnvCommand(); + $this->tester = new CommandTester($this->command); + } + + public function testCommandMetadata(): void + { + $this->assertSame('core:env', $this->command->getName()); + $this->assertSame('Generates new .env file', $this->command->getDescription()); + $this->assertSame('The command will generate new .env file from .env.example', $this->command->getHelp()); + } + + public function testExecShowsErrorWhenEnvExampleMissing(): void + { + $envExample = base_dir() . DS . '.env.example'; + $existed = file_exists($envExample); + + if ($existed) { + rename($envExample, $envExample . '.bak'); + } + + $this->tester->execute([]); + + $output = $this->tester->getDisplay(); + $this->assertStringContainsString('.env.example file not found', $output); + + if ($existed) { + rename($envExample . '.bak', $envExample); + } + } +} diff --git a/tests/Unit/Console/Commands/InstallToolkitCommandTest.php b/tests/Unit/Console/Commands/InstallToolkitCommandTest.php new file mode 100644 index 00000000..6f94c754 --- /dev/null +++ b/tests/Unit/Console/Commands/InstallToolkitCommandTest.php @@ -0,0 +1,35 @@ +command = new InstallToolkitCommand(); + } + + public function testCommandMetadata(): void + { + $this->assertSame('install:toolkit', $this->command->getName()); + $this->assertSame('Installs toolkit', $this->command->getDescription()); + $this->assertSame('The command will install Toolkit and its assets into your project', $this->command->getHelp()); + } + + public function testCommandArgumentsAreRegistered(): void + { + $definition = $this->command->getDefinition(); + + $this->assertTrue($definition->hasArgument('username')); + $this->assertTrue($definition->hasArgument('password')); + $this->assertTrue($definition->getArgument('username')->isRequired()); + $this->assertTrue($definition->getArgument('password')->isRequired()); + } +} diff --git a/tests/Unit/Console/Commands/KeyGenerateCommandTest.php b/tests/Unit/Console/Commands/KeyGenerateCommandTest.php new file mode 100644 index 00000000..2134037d --- /dev/null +++ b/tests/Unit/Console/Commands/KeyGenerateCommandTest.php @@ -0,0 +1,60 @@ +command = new KeyGenerateCommand(); + $this->command->setHelperSet(new HelperSet(['question' => new QuestionHelper()])); + $this->tester = new CommandTester($this->command); + } + + public function testCommandMetadata(): void + { + $this->assertSame('core:key', $this->command->getName()); + $this->assertSame('Generates and stores the application key', $this->command->getDescription()); + $this->assertSame('The command will generate APP_KEY and store in .env file', $this->command->getHelp()); + } + + public function testCommandOptionsAreRegistered(): void + { + $definition = $this->command->getDefinition(); + + $this->assertTrue($definition->hasOption('length')); + $this->assertTrue($definition->hasOption('yes')); + } + + public function testExecGeneratesKey(): void + { + $this->tester->execute(['--yes' => true, '--length' => 16]); + + $output = $this->tester->getDisplay(); + $this->assertStringContainsString('Application key successfully generated', $output); + $this->assertNotEmpty(env('APP_KEY')); + } + + public function testExecCancelsWhenNotConfirmed(): void + { + env('APP_KEY', 'existing-key'); + + $this->tester->setInputs(['n']); + $this->tester->execute([]); + + $output = $this->tester->getDisplay(); + $this->assertStringContainsString('Operation was canceled', $output); + } +} diff --git a/tests/Unit/Console/Commands/MigrationGenerateCommandTest.php b/tests/Unit/Console/Commands/MigrationGenerateCommandTest.php new file mode 100644 index 00000000..09166e0d --- /dev/null +++ b/tests/Unit/Console/Commands/MigrationGenerateCommandTest.php @@ -0,0 +1,34 @@ +command = new MigrationGenerateCommand(); + } + + public function testCommandMetadata(): void + { + $this->assertSame('migration:generate', $this->command->getName()); + $this->assertSame('Generates new migration file', $this->command->getDescription()); + } + + public function testCommandArgumentsAreRegistered(): void + { + $definition = $this->command->getDefinition(); + + $this->assertTrue($definition->hasArgument('action')); + $this->assertTrue($definition->hasArgument('table')); + $this->assertTrue($definition->getArgument('action')->isRequired()); + $this->assertTrue($definition->getArgument('table')->isRequired()); + } +} diff --git a/tests/Unit/Console/Commands/MigrationMigrateCommandTest.php b/tests/Unit/Console/Commands/MigrationMigrateCommandTest.php new file mode 100644 index 00000000..bf943bf0 --- /dev/null +++ b/tests/Unit/Console/Commands/MigrationMigrateCommandTest.php @@ -0,0 +1,34 @@ +command = new MigrationMigrateCommand(); + } + + public function testCommandMetadata(): void + { + $this->assertSame('migration:migrate', $this->command->getName()); + $this->assertSame('Migrates the migrations', $this->command->getDescription()); + } + + public function testCommandArgumentsAndOptionsAreRegistered(): void + { + $definition = $this->command->getDefinition(); + + $this->assertTrue($definition->hasArgument('direction')); + $this->assertFalse($definition->getArgument('direction')->isRequired()); + + $this->assertTrue($definition->hasOption('step')); + } +} diff --git a/tests/Unit/Console/Commands/ModuleGenerateCommandTest.php b/tests/Unit/Console/Commands/ModuleGenerateCommandTest.php new file mode 100644 index 00000000..0aace025 --- /dev/null +++ b/tests/Unit/Console/Commands/ModuleGenerateCommandTest.php @@ -0,0 +1,37 @@ +command = new ModuleGenerateCommand(); + } + + public function testCommandMetadata(): void + { + $this->assertSame('module:generate', $this->command->getName()); + $this->assertSame('Generate new module', $this->command->getDescription()); + $this->assertSame('The command will create files for new module', $this->command->getHelp()); + } + + public function testCommandArgumentsAndOptionsAreRegistered(): void + { + $definition = $this->command->getDefinition(); + + $this->assertTrue($definition->hasArgument('module')); + $this->assertTrue($definition->getArgument('module')->isRequired()); + + $this->assertTrue($definition->hasOption('yes')); + $this->assertTrue($definition->hasOption('template')); + $this->assertTrue($definition->hasOption('with-assets')); + } +} diff --git a/tests/Unit/Console/Commands/OpenApiCommandTest.php b/tests/Unit/Console/Commands/OpenApiCommandTest.php new file mode 100644 index 00000000..a3327166 --- /dev/null +++ b/tests/Unit/Console/Commands/OpenApiCommandTest.php @@ -0,0 +1,40 @@ +command = new OpenApiCommand(); + } + + public function testCommandMetadata(): void + { + $this->assertSame('install:openapi', $this->command->getName()); + $this->assertSame('Generates files for OpenApi UI', $this->command->getDescription()); + $this->assertSame('The command will publish OpenApi UI resources', $this->command->getHelp()); + } + + public function testCommandArgumentsAreRegistered(): void + { + $definition = $this->command->getDefinition(); + + $this->assertTrue($definition->hasArgument('module')); + $this->assertTrue($definition->getArgument('module')->isRequired()); + } + + public function testConstructorInitializesFileSystem(): void + { + $fs = $this->getPrivateProperty($this->command, 'fs'); + $this->assertInstanceOf(FileSystem::class, $fs); + } +} diff --git a/tests/Unit/Console/Commands/ResourceCacheClearCommandTest.php b/tests/Unit/Console/Commands/ResourceCacheClearCommandTest.php new file mode 100644 index 00000000..1f672a2d --- /dev/null +++ b/tests/Unit/Console/Commands/ResourceCacheClearCommandTest.php @@ -0,0 +1,62 @@ +command = new ResourceCacheClearCommand(); + $this->tester = new CommandTester($this->command); + } + + public function testCommandMetadata(): void + { + $this->assertSame('cache:clear', $this->command->getName()); + $this->assertSame('Clears resource cache', $this->command->getDescription()); + $this->assertSame('The command will clear the resource cache', $this->command->getHelp()); + } + + public function testCommandOptionsAreRegistered(): void + { + $definition = $this->command->getDefinition(); + + $this->assertTrue($definition->hasOption('all')); + $this->assertTrue($definition->hasOption('type')); + $this->assertTrue($definition->hasOption('module')); + } + + public function testConstructorInitializesFileSystem(): void + { + $fs = $this->getPrivateProperty($this->command, 'fs'); + $this->assertInstanceOf(FileSystem::class, $fs); + } + + public function testExecShowsErrorWhenNoOptionsProvided(): void + { + config()->set('view_cache', ['cache_dir' => 'cache']); + + $cacheDir = base_dir() . DS . 'cache'; + if (!is_dir($cacheDir)) { + mkdir($cacheDir, 0777, true); + } + + $this->tester->execute([]); + + $output = $this->tester->getDisplay(); + $this->assertStringContainsString('Please specify at least one of the following options', $output); + + @rmdir($cacheDir); + } +} diff --git a/tests/Unit/Console/Commands/RouteListCommandTest.php b/tests/Unit/Console/Commands/RouteListCommandTest.php new file mode 100644 index 00000000..58102ea4 --- /dev/null +++ b/tests/Unit/Console/Commands/RouteListCommandTest.php @@ -0,0 +1,32 @@ +command = new RouteListCommand(); + } + + public function testCommandMetadata(): void + { + $this->assertSame('route:list', $this->command->getName()); + $this->assertSame('Display all registered routes', $this->command->getDescription()); + } + + public function testCommandOptionsAreRegistered(): void + { + $definition = $this->command->getDefinition(); + + $this->assertTrue($definition->hasOption('module')); + $this->assertFalse($definition->getOption('module')->isValueRequired()); + } +} diff --git a/tests/Unit/Console/Commands/ServeCommandTest.php b/tests/Unit/Console/Commands/ServeCommandTest.php new file mode 100644 index 00000000..7b7079a5 --- /dev/null +++ b/tests/Unit/Console/Commands/ServeCommandTest.php @@ -0,0 +1,49 @@ +command = new ServeCommand(); + } + + public function testCommandMetadata(): void + { + $this->assertSame('serve', $this->command->getName()); + $this->assertSame('Serves the application on the PHP development server', $this->command->getDescription()); + } + + public function testCommandOptionsAreRegistered(): void + { + $definition = $this->command->getDefinition(); + + $this->assertTrue($definition->hasOption('host')); + $this->assertTrue($definition->hasOption('port')); + $this->assertTrue($definition->hasOption('open')); + } + + public function testBrowserCommandReturnsArrayForKnownPlatform(): void + { + $method = new \ReflectionMethod($this->command, 'browserCommand'); + $method->setAccessible(true); + + $result = $method->invoke($this->command, 'http://localhost:8000'); + + if (in_array(PHP_OS_FAMILY, ['Windows', 'Linux', 'Darwin'], true)) { + $this->assertIsArray($result); + $this->assertCount(2, $result); + $this->assertSame('http://localhost:8000', $result[1]); + } else { + $this->assertNull($result); + } + } +} diff --git a/tests/Unit/Console/Commands/VersionCommandTest.php b/tests/Unit/Console/Commands/VersionCommandTest.php new file mode 100644 index 00000000..e533d463 --- /dev/null +++ b/tests/Unit/Console/Commands/VersionCommandTest.php @@ -0,0 +1,25 @@ +command = new VersionCommand(); + } + + public function testCommandMetadata(): void + { + $this->assertSame('core:version', $this->command->getName()); + $this->assertSame('Core version', $this->command->getDescription()); + $this->assertSame('Printing the current version of the framework into the terminal', $this->command->getHelp()); + } +} From 4d74963fafd94916e081262a5b8eff343d500de2 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:33:50 +0400 Subject: [PATCH 35/39] Refactor Database SleekDB adapter and update KeyGenerateCommand test --- src/Database/Adapters/Sleekdb/SleekDbal.php | 4 ++-- .../Adapters/Sleekdb/Statements/Join.php | 2 +- .../Commands/KeyGenerateCommandTest.php | 21 +++++++++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/Database/Adapters/Sleekdb/SleekDbal.php b/src/Database/Adapters/Sleekdb/SleekDbal.php index 62c2df34..9e2e6af5 100644 --- a/src/Database/Adapters/Sleekdb/SleekDbal.php +++ b/src/Database/Adapters/Sleekdb/SleekDbal.php @@ -56,12 +56,12 @@ class SleekDbal implements DbalInterface protected $modifiedFields = []; /** - * @var array> + * @var array|string> */ protected array $criterias = []; /** - * @var array> + * @var array> */ protected array $havings = []; diff --git a/src/Database/Adapters/Sleekdb/Statements/Join.php b/src/Database/Adapters/Sleekdb/Statements/Join.php index ffb5fc20..b6efccad 100644 --- a/src/Database/Adapters/Sleekdb/Statements/Join.php +++ b/src/Database/Adapters/Sleekdb/Statements/Join.php @@ -65,7 +65,7 @@ private function applyJoin(QueryBuilder $queryBuilder, SleekDbal $currentItem, a $modelToJoin = unserialize($nextItem['model']); $switch = $nextItem['switch']; - if (!is_object($modelToJoin)) { + if (!$modelToJoin instanceof DbModel) { throw new RuntimeException('Failed to unserialize join model.'); } diff --git a/tests/Unit/Console/Commands/KeyGenerateCommandTest.php b/tests/Unit/Console/Commands/KeyGenerateCommandTest.php index 2134037d..ca9c58cd 100644 --- a/tests/Unit/Console/Commands/KeyGenerateCommandTest.php +++ b/tests/Unit/Console/Commands/KeyGenerateCommandTest.php @@ -6,7 +6,9 @@ use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Helper\HelperSet; use Quantum\Console\Commands\KeyGenerateCommand; +use Quantum\Environment\Environment; use Quantum\Tests\Unit\AppTestCase; +use Quantum\App\App; class KeyGenerateCommandTest extends AppTestCase { @@ -14,15 +16,34 @@ class KeyGenerateCommandTest extends AppTestCase private CommandTester $tester; + private string $envFilePath; + + private string $originalFileContent; + + /** @var array */ + private array $originalEnvData; + public function setUp(): void { parent::setUp(); + $this->envFilePath = App::getBaseDir() . DS . '.env.testing'; + $this->originalFileContent = (string) $this->fs->get($this->envFilePath); + $this->originalEnvData = $this->getPrivateProperty(Environment::getInstance(), 'envContent'); + $this->command = new KeyGenerateCommand(); $this->command->setHelperSet(new HelperSet(['question' => new QuestionHelper()])); $this->tester = new CommandTester($this->command); } + public function tearDown(): void + { + $this->fs->put($this->envFilePath, $this->originalFileContent); + $this->setPrivateProperty(Environment::getInstance(), 'envContent', $this->originalEnvData); + + parent::tearDown(); + } + public function testCommandMetadata(): void { $this->assertSame('core:key', $this->command->getName()); From 5ee7f77394a240d4d5358640251b0de9cc08ae84 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:53:04 +0400 Subject: [PATCH 36/39] Fix nullable configs passed to Twig Environment constructor for PHP 8+ compatibility --- src/Renderer/Adapters/TwigAdapter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Renderer/Adapters/TwigAdapter.php b/src/Renderer/Adapters/TwigAdapter.php index a792c4d4..4a5a52ba 100644 --- a/src/Renderer/Adapters/TwigAdapter.php +++ b/src/Renderer/Adapters/TwigAdapter.php @@ -67,7 +67,7 @@ public function render(string $view, array $params = []): string { $loader = $this->getLoader($view); - $twig = new Environment($loader, $this->configs); + $twig = new Environment($loader, $this->configs ?? []); $this->addFunctionsToTwig($twig); From 713a871e276cd9b1115ff7ecc3b68bc3ce1401b0 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:55:21 +0400 Subject: [PATCH 37/39] refactor: improve error handling, type safety, and test stability across core packages --- src/App/App.php | 7 ++++++- src/Auth/Factories/AuthFactory.php | 2 +- src/Cache/Adapters/FileAdapter.php | 5 +++-- .../Adapters/Sleekdb/Statements/Join.php | 6 +++++- src/Mailer/Adapters/SmtpAdapter.php | 2 +- src/Mailer/Traits/MailerTrait.php | 7 ++++++- src/Migration/Enums/ExceptionMessages.php | 2 ++ src/Migration/Exceptions/MigrationException.php | 8 ++++++++ src/Migration/MigrationManager.php | 5 ++--- src/Module/Enums/ExceptionMessages.php | 2 ++ src/Module/Exceptions/ModuleException.php | 8 ++++++++ src/Module/ModuleManager.php | 2 +- src/Storage/Traits/CloudAppTrait.php | 7 ++++++- src/Storage/UploadedFile.php | 14 ++++++++++++-- tests/Unit/Console/Commands/EnvCommandTest.php | 17 ++++++++++------- tests/_root/cron-tests/failing-task.php | 9 +++++++++ 16 files changed, 82 insertions(+), 21 deletions(-) create mode 100644 tests/_root/cron-tests/failing-task.php diff --git a/src/App/App.php b/src/App/App.php index c7dc5b56..406ba8f3 100644 --- a/src/App/App.php +++ b/src/App/App.php @@ -19,6 +19,7 @@ use Quantum\App\Exceptions\BaseException; use Quantum\App\Exceptions\AppException; use Quantum\App\Contracts\AppInterface; +use RuntimeException; /** * Class App @@ -42,7 +43,11 @@ public static function setBaseDir(string $baseDir): void public static function getBaseDir(): string { - return self::$baseDir ?? ''; + if (self::$baseDir === null || self::$baseDir === '') { + throw new RuntimeException('Base directory is not initialized.'); + } + + return self::$baseDir; } public function getAdapter(): AppInterface diff --git a/src/Auth/Factories/AuthFactory.php b/src/Auth/Factories/AuthFactory.php index edad0b1e..985b4f2a 100644 --- a/src/Auth/Factories/AuthFactory.php +++ b/src/Auth/Factories/AuthFactory.php @@ -137,6 +137,6 @@ private static function createJwtInstance(): JwtToken { return (new JwtToken()) ->setLeeway(1) - ->setClaims((array) config()->get('auth.claims')); + ->setClaims((array) config()->get('auth.' . AuthType::JWT . '.claims')); } } diff --git a/src/Cache/Adapters/FileAdapter.php b/src/Cache/Adapters/FileAdapter.php index 41939908..37525de3 100644 --- a/src/Cache/Adapters/FileAdapter.php +++ b/src/Cache/Adapters/FileAdapter.php @@ -128,8 +128,9 @@ public function set($key, $value, $ttl = null): bool $path = $this->getPath($key); $result = $this->fs->put($path, serialize($value)) !== false; - if ($result) { - touch($path, time() + $this->normalizeTtl($ttl)); + if ($result && !touch($path, time() + $this->normalizeTtl($ttl))) { + $this->fs->remove($path); + return false; } return $result; diff --git a/src/Database/Adapters/Sleekdb/Statements/Join.php b/src/Database/Adapters/Sleekdb/Statements/Join.php index b6efccad..f70dbfbc 100644 --- a/src/Database/Adapters/Sleekdb/Statements/Join.php +++ b/src/Database/Adapters/Sleekdb/Statements/Join.php @@ -50,7 +50,11 @@ public function joinTo(DbModel $model, bool $switch = true): DbalInterface */ private function applyJoins(): void { - if (!empty($this->joins) && $this->queryBuilder !== null) { + if (!empty($this->joins)) { + if ($this->queryBuilder === null) { + throw new RuntimeException('Cannot apply joins without an initialized query builder.'); + } + $this->applyJoin($this->queryBuilder, $this, $this->joins[0]); } } diff --git a/src/Mailer/Adapters/SmtpAdapter.php b/src/Mailer/Adapters/SmtpAdapter.php index e8bd0aca..ba313c03 100644 --- a/src/Mailer/Adapters/SmtpAdapter.php +++ b/src/Mailer/Adapters/SmtpAdapter.php @@ -178,7 +178,7 @@ public function getStringAttachments(): array */ protected function resolveMessageId(): string { - preg_match('/<(.*?)@/', preg_quote($this->mailer->getLastMessageID()), $matches); + preg_match('/<(.*?)@/', $this->mailer->getLastMessageID(), $matches); return $matches[1] ?? bin2hex(random_bytes(16)); } diff --git a/src/Mailer/Traits/MailerTrait.php b/src/Mailer/Traits/MailerTrait.php index 6458937e..9f8d0e0d 100644 --- a/src/Mailer/Traits/MailerTrait.php +++ b/src/Mailer/Traits/MailerTrait.php @@ -323,7 +323,12 @@ private function generateMessage(): string $message .= 'Content-Type: text/html; charset=UTF-8' . PHP_EOL . PHP_EOL; - $body = is_string($this->message) ? $this->message : ''; + if ($this->templatePath) { + $body = $this->createFromTemplate(); + } else { + $body = is_string($this->message) ? $this->message : ''; + } + return $message . ($body . PHP_EOL); } diff --git a/src/Migration/Enums/ExceptionMessages.php b/src/Migration/Enums/ExceptionMessages.php index abebb558..361c6f80 100644 --- a/src/Migration/Enums/ExceptionMessages.php +++ b/src/Migration/Enums/ExceptionMessages.php @@ -29,4 +29,6 @@ final class ExceptionMessages extends BaseExceptionMessages public const NOT_SUPPORTED_ACTION = 'The action `{%1}`, is not supported'; public const NOTHING_TO_MIGRATE = 'Nothing to migrate'; + + public const INVALID_MIGRATION_CLASS = 'Migration class `{%1}` must extend QtMigration'; } diff --git a/src/Migration/Exceptions/MigrationException.php b/src/Migration/Exceptions/MigrationException.php index 57e4f439..7fab5508 100644 --- a/src/Migration/Exceptions/MigrationException.php +++ b/src/Migration/Exceptions/MigrationException.php @@ -50,4 +50,12 @@ public static function nothingToMigrate(): self E_NOTICE ); } + + public static function invalidMigrationClass(string $className): self + { + return new self( + _message(ExceptionMessages::INVALID_MIGRATION_CLASS, [$className]), + E_ERROR + ); + } } diff --git a/src/Migration/MigrationManager.php b/src/Migration/MigrationManager.php index 92efab3a..34e70754 100644 --- a/src/Migration/MigrationManager.php +++ b/src/Migration/MigrationManager.php @@ -29,7 +29,6 @@ use Quantum\Storage\FileSystem; use Quantum\Database\Database; use ReflectionException; -use RuntimeException; /** * Class MigrationManager @@ -170,7 +169,7 @@ private function upgrade(): int $migration = new $migrationClassName(); if (!$migration instanceof QtMigration) { - throw new RuntimeException("Migration class $migrationClassName must extend QtMigration."); + throw MigrationException::invalidMigrationClass($migrationClassName); } $migration->up($this->tableFactory); @@ -208,7 +207,7 @@ private function downgrade(?int $step): int $migration = new $migrationClassName(); if (!$migration instanceof QtMigration) { - throw new RuntimeException("Migration class $migrationClassName must extend QtMigration."); + throw MigrationException::invalidMigrationClass($migrationClassName); } $migration->down($this->tableFactory); diff --git a/src/Module/Enums/ExceptionMessages.php b/src/Module/Enums/ExceptionMessages.php index faac113c..4b582546 100644 --- a/src/Module/Enums/ExceptionMessages.php +++ b/src/Module/Enums/ExceptionMessages.php @@ -35,4 +35,6 @@ final class ExceptionMessages extends BaseExceptionMessages public const MISSING_MODULE_DIRECTORY = 'Module directory does not exist, skipping config update.'; public const MODULE_ALREADY_EXISTS = 'A module or prefix named `{%1}` already exists'; + + public const DIRECTORY_LISTING_FAILED = 'Failed to list directory `{%1}`'; } diff --git a/src/Module/Exceptions/ModuleException.php b/src/Module/Exceptions/ModuleException.php index 694dc20d..4ec477d0 100644 --- a/src/Module/Exceptions/ModuleException.php +++ b/src/Module/Exceptions/ModuleException.php @@ -71,4 +71,12 @@ public static function moduleAlreadyExists(string $name): self E_ERROR ); } + + public static function directoryListingFailed(string $directory): self + { + return new self( + _message(ExceptionMessages::DIRECTORY_LISTING_FAILED, [$directory]), + E_ERROR + ); + } } diff --git a/src/Module/ModuleManager.php b/src/Module/ModuleManager.php index f3ce8f6d..435c7da8 100644 --- a/src/Module/ModuleManager.php +++ b/src/Module/ModuleManager.php @@ -159,7 +159,7 @@ private function copyDirectory(string $src, string $dst, bool $processTemplates, $dir = $this->fs->listDirectory($src); if (!is_array($dir)) { - return $copiedFiles; + throw ModuleException::directoryListingFailed($src); } foreach ($dir as $file) { diff --git a/src/Storage/Traits/CloudAppTrait.php b/src/Storage/Traits/CloudAppTrait.php index 0ab11dfc..33635f6a 100644 --- a/src/Storage/Traits/CloudAppTrait.php +++ b/src/Storage/Traits/CloudAppTrait.php @@ -51,6 +51,11 @@ public function sendRequest(string $uri, $data = null, array $headers = [], stri if ($this->accessTokenNeedsRefresh($code, $responseBody)) { $prevUrl = $this->httpClient->url(); + + if (empty($prevUrl)) { + throw new Exception('Cannot retry request: original URL is not available.', E_ERROR); + } + $prevData = $this->httpClient->getData(); $prevHeaders = $this->httpClient->getRequestHeaders(); @@ -60,7 +65,7 @@ public function sendRequest(string $uri, $data = null, array $headers = [], stri $prevHeaders['Authorization'] = 'Bearer ' . $accessToken; - $responseBody = $this->sendRequest($prevUrl ?? '', $prevData, $prevHeaders); + $responseBody = $this->sendRequest($prevUrl, $prevData, $prevHeaders); } else { throw new Exception( diff --git a/src/Storage/UploadedFile.php b/src/Storage/UploadedFile.php index b112073b..b8313c6a 100644 --- a/src/Storage/UploadedFile.php +++ b/src/Storage/UploadedFile.php @@ -217,8 +217,18 @@ public function getMimeType(): string if (!$this->mimetype) { $fileInfo = new finfo(FILEINFO_MIME); $mimetype = $fileInfo->file($this->getPathname()); - $mimetypeParts = is_string($mimetype) ? preg_split('/\s*[;,]\s*/', $mimetype) : false; - $this->mimetype = strtolower(is_array($mimetypeParts) ? $mimetypeParts[0] : ''); + + if (!is_string($mimetype)) { + throw new RuntimeException('Failed to determine MIME type for: ' . $this->getPathname()); + } + + $mimetypeParts = preg_split('/\s*[;,]\s*/', $mimetype); + + if (!is_array($mimetypeParts) || empty($mimetypeParts[0])) { + throw new RuntimeException('Failed to parse MIME type: ' . $mimetype); + } + + $this->mimetype = strtolower($mimetypeParts[0]); unset($fileInfo); } diff --git a/tests/Unit/Console/Commands/EnvCommandTest.php b/tests/Unit/Console/Commands/EnvCommandTest.php index f4975d05..6fc9979b 100644 --- a/tests/Unit/Console/Commands/EnvCommandTest.php +++ b/tests/Unit/Console/Commands/EnvCommandTest.php @@ -30,19 +30,22 @@ public function testCommandMetadata(): void public function testExecShowsErrorWhenEnvExampleMissing(): void { $envExample = base_dir() . DS . '.env.example'; + $backup = $envExample . '.' . uniqid('bak', true); $existed = file_exists($envExample); if ($existed) { - rename($envExample, $envExample . '.bak'); + rename($envExample, $backup); } - $this->tester->execute([]); + try { + $this->tester->execute([]); - $output = $this->tester->getDisplay(); - $this->assertStringContainsString('.env.example file not found', $output); - - if ($existed) { - rename($envExample . '.bak', $envExample); + $output = $this->tester->getDisplay(); + $this->assertStringContainsString('.env.example file not found', $output); + } finally { + if ($existed && file_exists($backup)) { + rename($backup, $envExample); + } } } } diff --git a/tests/_root/cron-tests/failing-task.php b/tests/_root/cron-tests/failing-task.php new file mode 100644 index 00000000..25c9e787 --- /dev/null +++ b/tests/_root/cron-tests/failing-task.php @@ -0,0 +1,9 @@ + 'failing-task', + 'expression' => '* * * * *', + 'callback' => function () { + throw new \Exception("Execution failed"); + } +]; From 860029c164accae8e09842b3255cc61ef12952b9 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:04:50 +0400 Subject: [PATCH 38/39] Removing and ignoring temp generated files for test --- .gitignore | 1 + tests/_root/cron-tests/failing-task.php | 9 --------- 2 files changed, 1 insertion(+), 9 deletions(-) delete mode 100644 tests/_root/cron-tests/failing-task.php diff --git a/.gitignore b/.gitignore index fe85b539..3b89caf4 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ Thumbs.db # Test & Runtime artifacts # ========================= /tests/_root/shared/store/* +/tests/_root/cron-tests/ /coverage/ *.log diff --git a/tests/_root/cron-tests/failing-task.php b/tests/_root/cron-tests/failing-task.php deleted file mode 100644 index 25c9e787..00000000 --- a/tests/_root/cron-tests/failing-task.php +++ /dev/null @@ -1,9 +0,0 @@ - 'failing-task', - 'expression' => '* * * * *', - 'callback' => function () { - throw new \Exception("Execution failed"); - } -]; From b2105533eb21b66bab3820bb64487cc964aff3d9 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:16:00 +0400 Subject: [PATCH 39/39] Adding unit tests for coverage patch --- tests/Unit/Console/QtCommandTest.php | 21 +++++++++++++++ tests/Unit/Mailer/MailerTest.php | 39 ++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/tests/Unit/Console/QtCommandTest.php b/tests/Unit/Console/QtCommandTest.php index c1c918dc..ebe89336 100644 --- a/tests/Unit/Console/QtCommandTest.php +++ b/tests/Unit/Console/QtCommandTest.php @@ -7,6 +7,7 @@ use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Helper\HelperSet; use Quantum\Tests\Unit\AppTestCase; +use RuntimeException; class QtCommandTest extends AppTestCase { @@ -97,4 +98,24 @@ public function testConfirmReturnsTrue(): void $this->assertTrue($this->command->confirm('Confirm action')); } + + public function testResolveInputThrowsWhenNotSet(): void + { + $command = new TestCommand(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Input is not set.'); + + $command->getArgument('uuid'); + } + + public function testResolveOutputThrowsWhenNotSet(): void + { + $command = new TestCommand(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Output is not set.'); + + $command->output('test'); + } } diff --git a/tests/Unit/Mailer/MailerTest.php b/tests/Unit/Mailer/MailerTest.php index 98fa204f..97260347 100644 --- a/tests/Unit/Mailer/MailerTest.php +++ b/tests/Unit/Mailer/MailerTest.php @@ -2,12 +2,18 @@ namespace Quantum\Tests\Unit\Mailer; +use Quantum\Mailer\Adapters\SendinblueAdapter; +use Quantum\Mailer\Adapters\MandrillAdapter; +use Quantum\Mailer\Adapters\SendgridAdapter; +use Quantum\Mailer\Adapters\MailgunAdapter; +use Quantum\Mailer\Adapters\ResendAdapter; use Quantum\Mailer\Exceptions\MailerException; use Quantum\Mailer\Contracts\MailerInterface; use Quantum\Mailer\Adapters\SmtpAdapter; use Quantum\Tests\Unit\AppTestCase; use Quantum\Mailer\Mailer; use Quantum\Loader\Setup; +use ReflectionMethod; class MailerTest extends AppTestCase { @@ -48,4 +54,37 @@ public function testMailerCallingInvalidMethod(): void $mailer->callingInvalidMethod(); } + + /** + * @dataProvider httpAdapterProvider + */ + public function testHttpAdapterGetTransportErrorsReturnsArray(string $adapterClass, array $params): void + { + $adapter = new $adapterClass($params); + + $httpClient = $this->getPrivateProperty($adapter, 'httpClient'); + $httpClient->createRequest('http://localhost'); + + $method = new ReflectionMethod($adapter, 'getTransportErrors'); + $method->setAccessible(true); + + $errors = $method->invoke($adapter); + + $this->assertIsArray($errors); + $this->assertEmpty($errors); + } + + /** + * @return array}> + */ + public function httpAdapterProvider(): array + { + return [ + 'mailgun' => [MailgunAdapter::class, ['api_key' => 'test', 'domain' => 'test.com']], + 'mandrill' => [MandrillAdapter::class, ['api_key' => 'test']], + 'resend' => [ResendAdapter::class, ['api_key' => 'test']], + 'sendgrid' => [SendgridAdapter::class, ['api_key' => 'test']], + 'sendinblue' => [SendinblueAdapter::class, ['api_key' => 'test']], + ]; + } }