From 02ced32be5ee68cb2ca4e118a56bdec7514e8487 Mon Sep 17 00:00:00 2001 From: Kim Sandell Date: Mon, 30 Mar 2026 07:56:24 +0200 Subject: [PATCH 01/10] docs: add complete reference for all Spin\Helpers classes Expand User-Guide/Helpers.md section 2 with full API documentation for all six helper classes: ArrayToXml, Cipher, Hash, UUID, JWT, EWT. JWT and EWT were previously undocumented. Existing stubs for ArrayToXml, Cipher, Hash, and UUID are replaced with complete method signatures, parameter descriptions, usage examples, and guidance on when to use each class (e.g. JWT vs EWT, UUID v4 vs v7). --- doc/User-Guide/Helpers.md | 239 +++++++++++++++++++++++++++++++++++--- 1 file changed, 221 insertions(+), 18 deletions(-) diff --git a/doc/User-Guide/Helpers.md b/doc/User-Guide/Helpers.md index 53c500d..2f48f2f 100644 --- a/doc/User-Guide/Helpers.md +++ b/doc/User-Guide/Helpers.md @@ -28,6 +28,8 @@ * [2.2. Cipher](#22-cipher) * [2.3. Hash](#23-hash) * [2.4. UUID](#24-uuid) +* [2.5. JWT](#25-jwt) +* [2.6. EWT](#26-ewt) @@ -306,42 +308,243 @@ return responseFile($filename); --- # 2. Global Helper Objects +Helper classes live under the `Spin\Helpers` namespace. All methods are static unless noted otherwise. + ## 2.1. ArrayToXml -The `ArrayToXml()` converts an `$array` to a `$xml` document. + +Converts a PHP associative array to an XML document string. ```php -$array = []; -$array['key'] = 'value'; -# Convert array to XML document -$xml = (new \Spin\Helpers\ArrayToXml())->buildXML($array); +public function __construct(string $xmlVersion = '1.0', string $xmlEncoding = 'UTF-8') +public function buildXML(array $data, string $startElement = 'data'): string|false +``` + +Returns the XML as a Base64-encoded string on success, `false` on failure. + +**Special key prefixes** control how array keys are mapped to XML: + +| Prefix | Effect | Example key | +|--------|--------|-------------| +| `@` | XML attribute on the parent element | `@id` | +| `%` | Text content of the parent element | `%` | +| `#` | CDATA section | `#note` | +| `!` | CDATA section (alternative) | `!note` | + +```php +# Basic usage +$xml = (new \Spin\Helpers\ArrayToXml())->buildXML([ + 'name' => 'Alice', + 'age' => 30, +]); + +# With XML attributes and CDATA +$xml = (new \Spin\Helpers\ArrayToXml())->buildXML([ + '@id' => 42, // becomes + 'name' => 'Alice', + 'bio' => [ + '#content' => 'Contains & chars', // wrapped in CDATA + ], +], 'user'); ``` +--- + ## 2.2. Cipher -The `Cipher` helper encrypts/decrypts strings using OpenSSL. + +Encrypts and decrypts strings using OpenSSL. All methods are static. + +```php +public static function encrypt(string $data, string $secret = '', string $algorithm = 'AES-256-CBC'): string +public static function decrypt(string $data, string $secret = '', string $algorithm = 'AES-256-CBC'): bool|string +public static function encryptEx(string $data, string $secret, string $cipher = 'aes-256-ctr', string $hashAlgo = 'sha3-512'): string +public static function decryptEx(string $input, string $secret): mixed +public static function getMethods(): array +``` + +`encrypt` / `decrypt` use a random IV and return Base64-encoded ciphertext. + +`encryptEx` / `decryptEx` add an HMAC signature. The token format is: +``` +cipher[hashAlgo]:base64(iv).base64(encrypted).base64(hmac) +``` +`decryptEx` throws an `\Exception` if the HMAC does not verify. ```php -# Encrypt a value -$encryptedValue = \Spin\Helpers\Cipher::encrypt( $plain, 'secret', 'AES-256-CBC' ); +# Basic encrypt / decrypt +$secret = env('APP_SECRET'); +$encrypted = \Spin\Helpers\Cipher::encrypt('sensitive data', $secret); +$plain = \Spin\Helpers\Cipher::decrypt($encrypted, $secret); + +# Extended encrypt / decrypt (with HMAC verification) +$token = \Spin\Helpers\Cipher::encryptEx('sensitive data', $secret); +try { + $plain = \Spin\Helpers\Cipher::decryptEx($token, $secret); +} catch (\Exception $e) { + // tampered or wrong secret +} -# Decrypt a value -$plain = \Spin\Helpers\Cipher::decrypt( $encryptedValue, 'secret', 'AES-256-CBC' ); +# List available OpenSSL cipher methods +$methods = \Spin\Helpers\Cipher::getMethods(); ``` +--- + ## 2.3. Hash -The `Hash` helper produces hashes using OpenSSL digest methods. + +Generates and verifies cryptographic digests using OpenSSL. All methods are static. + +```php +public static function generate(string $data, string $method = 'SHA256'): string +public static function check(string $data, string $hash, string $method = 'SHA256'): bool +public static function getMethods(): array +``` + +`check` uses a timing-safe string comparison to prevent timing attacks. ```php -# Produce a hash -$digest = \Spin\Helpers\Hash::generate('This is the data','SHA256'); +# Generate a hash +$hash = \Spin\Helpers\Hash::generate('my data'); + +# Verify a hash +if (\Spin\Helpers\Hash::check('my data', $hash)) { + // data is unchanged +} + +# List available OpenSSL digest methods +$methods = \Spin\Helpers\Hash::getMethods(); ``` +--- + ## 2.4. UUID -The `UUID` helper produces UUID v3, v4 and v5 unique UUID's. + +Generates and validates UUIDs using the Ramsey UUID library. All methods are static. + +```php +public static function generate(): string // v7 — default +public static function v3(string $namespace, string $name): string +public static function v4(): string +public static function v5(string $namespace, string $name): string +public static function v6(): string +public static function v7(): string +public static function is_valid(string $uuid): bool +``` + +| Version | Algorithm | Use case | +|---------|-----------|----------| +| v3 | Namespace + MD5 | Deterministic from a name | +| v4 | Random | General-purpose unique IDs | +| v5 | Namespace + SHA1 | Deterministic, collision-resistant | +| v6 | Time-based (legacy) | Ordered, backwards-compatible with v1 | +| v7 | Time-based (recommended) | Ordered, sortable, database-friendly | + +`generate()` returns a v7 UUID. Prefer v7 for database primary keys (monotonically increasing, index-friendly). Use v4 when ordering is irrelevant. + +`is_valid` validates UUIDs of versions v3, v4, and v5. + +```php +# Default (v7 — time-ordered, ideal for DB primary keys) +$id = \Spin\Helpers\UUID::generate(); + +# Random v4 +$id = \Spin\Helpers\UUID::v4(); + +# Deterministic v5 (same namespace + name always yields the same UUID) +$ns = \Spin\Helpers\UUID::v4(); // generate a namespace once, store it +$id = \Spin\Helpers\UUID::v5($ns, 'user@example.com'); + +# Validate +if (\Spin\Helpers\UUID::is_valid($id)) { + // valid UUID +} +``` + +--- + +## 2.5. JWT + +Signs and verifies JSON Web Tokens via the `firebase/php-jwt` library. All methods are static. + +```php +public static function encode(array $payload, string $key, string $alg, ?string $keyId = null, ?array $head = null): string +public static function decode(string $jwt, string $key, string $algo = 'HS256'): array +public static function sign(string $msg, string $key, string $alg): string +``` + +**Supported algorithms:** `HS256`, `HS384`, `HS512`, `RS256`, `RS384`, `RS512`, `ES256`, `ES384` + +**Standard payload claims:** + +| Claim | Type | Description | +|-------|------|-------------| +| `sub` | string | Subject (e.g. user ID) | +| `iat` | int | Issued-at timestamp | +| `exp` | int | Expiry timestamp | +| `nbf` | int | Not-before timestamp | ```php -# UUIDv4 GUID -$uuidv4 = \Spin\Helpers\UUID::generate(); +$secret = env('APP_SECRET'); -# UUIDv5 GUID -$uuidv5 = \Spin\Helpers\UUID::v5($uuidv4,'My v5 UUID'); +# Create a token +$token = \Spin\Helpers\JWT::encode([ + 'sub' => (string) $userId, + 'iat' => time(), + 'exp' => time() + 3600, +], $secret, 'HS256'); + +# Decode and verify +try { + $payload = \Spin\Helpers\JWT::decode($token, $secret, 'HS256'); + $userId = $payload['sub']; +} catch (\Exception $e) { + // invalid signature, expired, or malformed token +} + +# Middleware pattern — extract Bearer token and validate +$authHeader = getRequest()->getHeaderLine('Authorization'); +if (str_starts_with($authHeader, 'Bearer ')) { + $token = substr($authHeader, 7); + $payload = \Spin\Helpers\JWT::decode($token, $secret, 'HS256'); + container('jwt_claims', $payload); +} +``` + +--- + +## 2.6. EWT + +EWT (Encrypted Web Token) is a custom token format where the payload is **encrypted** (not just signed). Use EWT instead of JWT when the payload contains sensitive data that must not be readable by the client. + +Unlike JWT (which only signs), EWT encrypts the payload with AES and includes an HMAC signature. `decode` returns `null` if signature verification fails rather than throwing. + +```php +public static function encode(mixed $data, string $secret, string $hash = 'sha256', string $alg = 'aes-256-ctr', ?string $iv = null): string +public static function decode(string $data, string $secret): string|null +public static function base64url_encode(mixed $input): string +public static function base64url_decode(string $input): mixed ``` + +Token format: `base64url(header).base64url(encrypted_payload).base64url(hmac_signature)` + +```php +$secret = env('APP_SECRET'); + +# Encrypt a payload +$token = \Spin\Helpers\EWT::encode(['user_id' => 42, 'role' => 'admin'], $secret); + +# Decrypt and verify +$payload = \Spin\Helpers\EWT::decode($token, $secret); +if ($payload === null) { + // tampered token or wrong secret +} +$data = json_decode($payload, true); +``` + +**JWT vs EWT — when to use which:** + +| | JWT | EWT | +|---|-----|-----| +| Payload visible to client? | Yes (Base64-decoded) | No (encrypted) | +| Signature verified? | Yes | Yes (HMAC) | +| Use when | Claims can be public | Claims are sensitive | +| Example | Auth roles, user ID | Internal session data, PII | From cc1c02954612979c70e58b657b478e7e6140ac2f Mon Sep 17 00:00:00 2001 From: Kim Sandell Date: Mon, 30 Mar 2026 08:16:02 +0200 Subject: [PATCH 02/10] feat(exceptions): add CacheException and wire into cache adapters --- src/Cache/Adapters/Apcu.php | 3 +- src/Cache/Adapters/Redis.php | 3 +- src/Exceptions/CacheException.php | 20 +++++++++ tests/Cache/Adapters/ApcuTest.php | 3 +- tests/Cache/Adapters/RedisTest.php | 3 +- tests/Exceptions/CacheExceptionTest.php | 54 +++++++++++++++++++++++++ 6 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 src/Exceptions/CacheException.php create mode 100644 tests/Exceptions/CacheExceptionTest.php diff --git a/src/Cache/Adapters/Apcu.php b/src/Cache/Adapters/Apcu.php index 095519e..663ccbd 100644 --- a/src/Cache/Adapters/Apcu.php +++ b/src/Cache/Adapters/Apcu.php @@ -11,6 +11,7 @@ namespace Spin\Cache\Adapters; use Spin\Cache\AbstractCacheAdapter; +use \Spin\Exceptions\CacheException; class Apcu extends AbstractCacheAdapter { @@ -21,7 +22,7 @@ public function __construct(array $options = []) # Check if APCu extension is loaded and available if (!\extension_loaded('apcu') || \ini_get('apc.enabled') !== '1') { - throw new \RuntimeException(sprintf('Cache driver %s not available', $this->getDriver())); + throw new CacheException(sprintf('Cache driver %s not available', $this->getDriver())); } # Set the version of the APCu library diff --git a/src/Cache/Adapters/Redis.php b/src/Cache/Adapters/Redis.php index ef11086..261940e 100644 --- a/src/Cache/Adapters/Redis.php +++ b/src/Cache/Adapters/Redis.php @@ -27,6 +27,7 @@ use Spin\Cache\AbstractCacheAdapter; use Predis\Client as RedisClient; +use \Spin\Exceptions\CacheException; class Redis extends AbstractCacheAdapter { @@ -43,7 +44,7 @@ class Redis extends AbstractCacheAdapter public function __construct(array $connectionDetails = []) { if (($connectionDetails['options'] ?? null) === null) { - throw new \RuntimeException("Empty Redis connection options"); + throw new CacheException("Empty Redis connection options"); } # Set $driver and $connectionDetails diff --git a/src/Exceptions/CacheException.php b/src/Exceptions/CacheException.php new file mode 100644 index 0000000..91c06fe --- /dev/null +++ b/src/Exceptions/CacheException.php @@ -0,0 +1,20 @@ +markTestSkipped('Cannot test APCu unavailable scenario when APCu is available'); } - $this->expectException(\RuntimeException::class); + $this->expectException(CacheException::class); $this->expectExceptionMessage('Cache driver APCu not available'); new Apcu(); diff --git a/tests/Cache/Adapters/RedisTest.php b/tests/Cache/Adapters/RedisTest.php index 68b0e58..a28be02 100644 --- a/tests/Cache/Adapters/RedisTest.php +++ b/tests/Cache/Adapters/RedisTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\TestCase; use Psr\SimpleCache\InvalidArgumentException; use Spin\Cache\Adapters\Redis; +use Spin\Exceptions\CacheException; class RedisTest extends TestCase { @@ -44,7 +45,7 @@ public function testRedisAdapterCreated(): void public function testRedisConnectionOptionsRequired(): void { - $this->expectException(\RuntimeException::class); + $this->expectException(CacheException::class); $this->expectExceptionMessage('Empty Redis connection options'); new Redis([]); diff --git a/tests/Exceptions/CacheExceptionTest.php b/tests/Exceptions/CacheExceptionTest.php new file mode 100644 index 0000000..0a6e4f4 --- /dev/null +++ b/tests/Exceptions/CacheExceptionTest.php @@ -0,0 +1,54 @@ +expectException(CacheException::class); + throw new CacheException('Cache adapter not available'); + } + + public function testCacheExceptionExtendsSpinException(): void + { + $this->assertInstanceOf(SpinException::class, new CacheException('test')); + } + + public function testCacheExceptionExtendsBaseException(): void + { + $this->assertInstanceOf(\Exception::class, new CacheException('test')); + } + + public function testCacheExceptionPreservesMessage(): void + { + $e = new CacheException('Cache driver Redis not available'); + $this->assertSame('Cache driver Redis not available', $e->getMessage()); + } + + public function testCacheExceptionPreservesCode(): void + { + $this->assertSame(42, (new CacheException('error', 42))->getCode()); + } + + public function testCacheExceptionPreservesPrevious(): void + { + $previous = new \RuntimeException('original'); + $this->assertSame($previous, (new CacheException('wrapper', 0, $previous))->getPrevious()); + } + + public function testCacheExceptionCanBeCaughtAsSpinException(): void + { + $caught = null; + try { + throw new CacheException('test'); + } catch (SpinException $e) { + $caught = $e; + } + $this->assertInstanceOf(CacheException::class, $caught); + } +} From 047e1cc0e274e6ef7ede03306e953b64ad2854a7 Mon Sep 17 00:00:00 2001 From: Kim Sandell Date: Mon, 30 Mar 2026 08:17:49 +0200 Subject: [PATCH 03/10] feat(exceptions): add ConfigException and wire into Config class --- src/Core/Config.php | 12 +++--- src/Exceptions/ConfigException.php | 20 +++++++++ tests/Exceptions/ConfigExceptionTest.php | 54 ++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 src/Exceptions/ConfigException.php create mode 100644 tests/Exceptions/ConfigExceptionTest.php diff --git a/src/Core/Config.php b/src/Core/Config.php index 365c0e4..513c1ba 100644 --- a/src/Core/Config.php +++ b/src/Core/Config.php @@ -15,7 +15,7 @@ namespace Spin\Core; use \Exception; -use \Spin\Exceptions\SpinException; +use \Spin\Exceptions\ConfigException; /** * Loads, merges, and persists environment-specific JSON configuration for the @@ -44,7 +44,7 @@ class Config extends AbstractBaseClass implements ConfigInterface * @param string $appPath Path to the /app folder * @param string $environment Name of the environment * - * @throws Exception + * @throws ConfigException */ public function __construct(string $appPath, string $environment) { @@ -98,13 +98,13 @@ public function load(string $filename): self try { $configArray = \json_decode(\file_get_contents($filename), true, 512, JSON_THROW_ON_ERROR); } catch (\JsonException $e) { - throw new SpinException(sprintf("Invalid JSON file %s, error was %s", $filename, $e->getMessage())); + throw new ConfigException(sprintf("Invalid JSON file %s, error was %s", $filename, $e->getMessage()), 0, $e); } if ($configArray) { $this->confValues = $this->replaceEnvMacros($configArray); } else { - throw new SpinException('Invalid JSON file "' . $filename . '"'); + throw new ConfigException('Invalid JSON file "' . $filename . '"'); } } @@ -130,14 +130,14 @@ public function loadAndMerge(string $filename): self try { $configArray = \json_decode(\file_get_contents($filename), true, 512, JSON_THROW_ON_ERROR); } catch (\JsonException $e) { - throw new SpinException(sprintf("Invalid JSON file %s, error was %s", $filename, $e->getMessage())); + throw new ConfigException(sprintf("Invalid JSON file %s, error was %s", $filename, $e->getMessage()), 0, $e); } if ($configArray) { # Merge the Config with existing config $this->confValues = $this->replaceEnvMacros(\array_replace_recursive($this->confValues, $configArray)); } else { - throw new SpinException('Invalid JSON file "' . $filename . '"'); + throw new ConfigException('Invalid JSON file "' . $filename . '"'); } } diff --git a/src/Exceptions/ConfigException.php b/src/Exceptions/ConfigException.php new file mode 100644 index 0000000..5719764 --- /dev/null +++ b/src/Exceptions/ConfigException.php @@ -0,0 +1,20 @@ +expectException(ConfigException::class); + throw new ConfigException('Invalid JSON file config.json'); + } + + public function testConfigExceptionExtendsSpinException(): void + { + $this->assertInstanceOf(SpinException::class, new ConfigException('test')); + } + + public function testConfigExceptionExtendsBaseException(): void + { + $this->assertInstanceOf(\Exception::class, new ConfigException('test')); + } + + public function testConfigExceptionPreservesMessage(): void + { + $e = new ConfigException('Invalid JSON file "config-production.json"'); + $this->assertSame('Invalid JSON file "config-production.json"', $e->getMessage()); + } + + public function testConfigExceptionPreservesCode(): void + { + $this->assertSame(500, (new ConfigException('error', 500))->getCode()); + } + + public function testConfigExceptionPreservesPrevious(): void + { + $previous = new \JsonException('json error'); + $this->assertSame($previous, (new ConfigException('wrapper', 0, $previous))->getPrevious()); + } + + public function testConfigExceptionCanBeCaughtAsSpinException(): void + { + $caught = null; + try { + throw new ConfigException('test'); + } catch (SpinException $e) { + $caught = $e; + } + $this->assertInstanceOf(ConfigException::class, $caught); + } +} From efe5517f6325c4f3157da270976a2c7160ea3bcb Mon Sep 17 00:00:00 2001 From: Kim Sandell Date: Mon, 30 Mar 2026 08:18:56 +0200 Subject: [PATCH 04/10] feat(exceptions): add DatabaseException --- src/Exceptions/DatabaseException.php | 20 ++++++++ tests/Exceptions/DatabaseExceptionTest.php | 54 ++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 src/Exceptions/DatabaseException.php create mode 100644 tests/Exceptions/DatabaseExceptionTest.php diff --git a/src/Exceptions/DatabaseException.php b/src/Exceptions/DatabaseException.php new file mode 100644 index 0000000..9ad5e49 --- /dev/null +++ b/src/Exceptions/DatabaseException.php @@ -0,0 +1,20 @@ +expectException(DatabaseException::class); + throw new DatabaseException('Query execution failed'); + } + + public function testDatabaseExceptionExtendsSpinException(): void + { + $this->assertInstanceOf(SpinException::class, new DatabaseException('test')); + } + + public function testDatabaseExceptionExtendsBaseException(): void + { + $this->assertInstanceOf(\Exception::class, new DatabaseException('test')); + } + + public function testDatabaseExceptionPreservesMessage(): void + { + $e = new DatabaseException('SQLSTATE[HY000]: General error'); + $this->assertSame('SQLSTATE[HY000]: General error', $e->getMessage()); + } + + public function testDatabaseExceptionPreservesCode(): void + { + $this->assertSame(1045, (new DatabaseException('error', 1045))->getCode()); + } + + public function testDatabaseExceptionPreservesPrevious(): void + { + $previous = new \PDOException('pdo error'); + $this->assertSame($previous, (new DatabaseException('wrapper', 0, $previous))->getPrevious()); + } + + public function testDatabaseExceptionCanBeCaughtAsSpinException(): void + { + $caught = null; + try { + throw new DatabaseException('test'); + } catch (SpinException $e) { + $caught = $e; + } + $this->assertInstanceOf(DatabaseException::class, $caught); + } +} From 088eb299006658ee6ddec4f7345cdcd3ececbb68 Mon Sep 17 00:00:00 2001 From: Kim Sandell Date: Mon, 30 Mar 2026 08:19:38 +0200 Subject: [PATCH 05/10] feat(exceptions): add MiddlewareException --- src/Exceptions/MiddlewareException.php | 20 ++++++++ tests/Exceptions/MiddlewareExceptionTest.php | 54 ++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 src/Exceptions/MiddlewareException.php create mode 100644 tests/Exceptions/MiddlewareExceptionTest.php diff --git a/src/Exceptions/MiddlewareException.php b/src/Exceptions/MiddlewareException.php new file mode 100644 index 0000000..3b384c9 --- /dev/null +++ b/src/Exceptions/MiddlewareException.php @@ -0,0 +1,20 @@ +expectException(MiddlewareException::class); + throw new MiddlewareException('Middleware initialization failed'); + } + + public function testMiddlewareExceptionExtendsSpinException(): void + { + $this->assertInstanceOf(SpinException::class, new MiddlewareException('test')); + } + + public function testMiddlewareExceptionExtendsBaseException(): void + { + $this->assertInstanceOf(\Exception::class, new MiddlewareException('test')); + } + + public function testMiddlewareExceptionPreservesMessage(): void + { + $e = new MiddlewareException('Authentication middleware failed to initialize'); + $this->assertSame('Authentication middleware failed to initialize', $e->getMessage()); + } + + public function testMiddlewareExceptionPreservesCode(): void + { + $this->assertSame(403, (new MiddlewareException('error', 403))->getCode()); + } + + public function testMiddlewareExceptionPreservesPrevious(): void + { + $previous = new \RuntimeException('inner error'); + $this->assertSame($previous, (new MiddlewareException('wrapper', 0, $previous))->getPrevious()); + } + + public function testMiddlewareExceptionCanBeCaughtAsSpinException(): void + { + $caught = null; + try { + throw new MiddlewareException('test'); + } catch (SpinException $e) { + $caught = $e; + } + $this->assertInstanceOf(MiddlewareException::class, $caught); + } +} From 9508b2daf95c881b74062fc70f0ac28a80b495dd Mon Sep 17 00:00:00 2001 From: Kim Sandell Date: Mon, 30 Mar 2026 08:31:27 +0200 Subject: [PATCH 06/10] feat(exceptions): wire DatabaseException into PdoConnection throw sites --- src/Database/PdoConnection.php | 6 ++++-- src/Database/PdoConnectionInterface.php | 6 ++++-- tests/Database/Drivers/Pdo/MySqlTest.php | 13 +++++++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/Database/PdoConnection.php b/src/Database/PdoConnection.php index edb0284..9832c7c 100644 --- a/src/Database/PdoConnection.php +++ b/src/Database/PdoConnection.php @@ -15,6 +15,8 @@ namespace Spin\Database; +use \Spin\Exceptions\DatabaseException; + class PdoConnection extends \PDO implements PdoConnectionInterface { /** @@ -494,7 +496,7 @@ public function rawQuery(string $sql, array $params=[], bool $autoTransactions=t $this->rollBack(); } - throw $e; + throw new DatabaseException($e->getMessage(), (int)$e->getCode(), $e); } return $rows; @@ -545,7 +547,7 @@ public function rawExec(string $sql, array $params = [], bool $autoTransactions $this->rollBack(); } - throw $e; + throw new DatabaseException($e->getMessage(), (int)$e->getCode(), $e); } return $result; diff --git a/src/Database/PdoConnectionInterface.php b/src/Database/PdoConnectionInterface.php index 8bc85d8..d529540 100644 --- a/src/Database/PdoConnectionInterface.php +++ b/src/Database/PdoConnectionInterface.php @@ -14,6 +14,8 @@ namespace Spin\Database; +use \Spin\Exceptions\DatabaseException; + interface PdoConnectionInterface { # PDO Class Interface (must be commented out because of declaration errors) @@ -267,7 +269,7 @@ public function setClientVersion(string $clientVersion): PdoConnectionInterface; * * @return array Array with fetched rows * - * @throws \Exception + * @throws DatabaseException */ public function rawQuery(string $sql, array $params = [], bool $autoTransactions = true): array; @@ -280,7 +282,7 @@ public function rawQuery(string $sql, array $params = [], bool $autoTransactions * * @return null|int `null` or number of rows affected * - * @throws \Exception + * @throws DatabaseException */ public function rawExec(string $sql, array $params = [], bool $autoTransactions = true): ?int; } diff --git a/tests/Database/Drivers/Pdo/MySqlTest.php b/tests/Database/Drivers/Pdo/MySqlTest.php index c96dfaf..39cba3a 100644 --- a/tests/Database/Drivers/Pdo/MySqlTest.php +++ b/tests/Database/Drivers/Pdo/MySqlTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\TestCase; use Spin\Database\Drivers\Pdo\MySql; +use Spin\Exceptions\DatabaseException; class MySqlTest extends TestCase { @@ -296,6 +297,18 @@ public function testAutoTransactionHandling(): void $this->assertEquals(1, $affectedRows); } + public function testRawQueryThrowsDatabaseException(): void + { + $this->expectException(DatabaseException::class); + $this->connection->rawQuery('SELECT * FROM nonexistent_table_xyz_abc', []); + } + + public function testRawExecThrowsDatabaseException(): void + { + $this->expectException(DatabaseException::class); + $this->connection->rawExec('INSERT INTO nonexistent_table_xyz_abc (col) VALUES (:val)', [':val' => 'x']); + } + } From c267afaaf382e594b79c4466a32bd85032667e78 Mon Sep 17 00:00:00 2001 From: Kim Sandell Date: Mon, 30 Mar 2026 08:31:39 +0200 Subject: [PATCH 07/10] feat(exceptions): wire DatabaseException into ConnectionManager throw sites --- src/Core/ConnectionManager.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Core/ConnectionManager.php b/src/Core/ConnectionManager.php index 95d657b..6b609db 100644 --- a/src/Core/ConnectionManager.php +++ b/src/Core/ConnectionManager.php @@ -27,6 +27,7 @@ use \Spin\Core\ConnectionManagerInterface; use \Spin\Database\PdoConnection; use \Spin\Database\PdoConnectionInterface; +use \Spin\Exceptions\DatabaseException; /** * Centralized factory/pool for database connections. Resolves connection @@ -178,10 +179,11 @@ protected function createConnection(string $connectionName, array $params=[]) # throw new exception only if trace is modified if($beforeTraceLength != \strlen($trace)) { - # new exception - throw new \Exception($e->getMessage(), $e->getCode()); + # new exception without $previous — intentionally omitted to prevent + # password leakage via getPrevious()->getTraceAsString() + throw new DatabaseException($e->getMessage(), (int)$e->getCode()); } - throw $e; + throw new DatabaseException($e->getMessage(), (int)$e->getCode(), $e); } } } From 2f66ee6d7d0730f497606b92ccd55e044f07358a Mon Sep 17 00:00:00 2001 From: Kim Sandell Date: Mon, 30 Mar 2026 08:35:43 +0200 Subject: [PATCH 08/10] chore: bump version to 0.0.40 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 12a74d7..4fe2fe8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.39 +0.0.40 From c99376049fcc5a7597537577619da6d78088dba6 Mon Sep 17 00:00:00 2001 From: Kim Sandell Date: Mon, 30 Mar 2026 08:37:32 +0200 Subject: [PATCH 09/10] chore: bump version to 0.0.40 in all version files --- composer.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 5242e8b..c257b1c 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "celarius/spin-framework", "description": "A super lightweight PHP UI/REST Framework", - "version": "0.0.39", + "version": "0.0.40", "keywords": [ "php8", "php-framework", diff --git a/package.json b/package.json index 69516ec..897b5cc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "spin-framework", "title": "Spin Framework", - "version": "0.0.39", + "version": "0.0.40", "homepage": "https://github.com/Celarius/spin-framework", "description": "A super lightweight PHP UI/REST Framework", "author": { From a68aa2cd7e0803943291a5a41fede3dcad8a2b48 Mon Sep 17 00:00:00 2001 From: Kim Sandell Date: Mon, 30 Mar 2026 08:39:49 +0200 Subject: [PATCH 10/10] chore: update changelog for 0.0.40 --- changelog.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/changelog.md b/changelog.md index 116ca70..142cea4 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,11 @@ # Changelog SPIN Framework Changelog +## 0.0.40 +- **Feature:** Add domain exception classes: `CacheException`, `ConfigException`, `DatabaseException`, `MiddlewareException` — all extending `SpinException` +- **Feature:** Wire `CacheException` into cache adapters (APCu, Redis), `ConfigException` into `Config`, `DatabaseException` into `PdoConnection` and `ConnectionManager` +- **Fix:** `ConnectionManager` password-sanitization now throws `DatabaseException` without `$previous` on tainted traces, preventing credential leakage + ## 0.0.39 - **Feature:** `.env` file auto-loading — `DotEnv::load()` reads `.env` from the project root at startup, populating environment variables before config macro expansion and `env()` calls; real process env vars always take precedence - **Fix:** `${env:VAR:default}` inline default syntax in config macros now works as documented; missing variables now resolve to the specified default instead of silently dropping to empty string