Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
"open-telemetry/context": "^1.5",
"open-telemetry/exporter-otlp": "^1.4",
"open-telemetry/sdk": "^1.15",
"open-telemetry/sem-conv": "^1.38",
"open-telemetry/sem-conv": "^1.45",
"paragonie/constant_time_encoding": "^3.1",
"phpoption/phpoption": "^1.9",
"phpstan/phpdoc-parser": "^2.3",
Expand Down
12 changes: 12 additions & 0 deletions docs/plans/2026-10-22-0911-framework-correctness-improvements.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,18 @@ The 0.4 integration preserves both sets of supported behavior. Record further fr
- Preserve chained cancellation through model pruning callbacks and managed connection destruction. Pruning stops without reporting cancellation; discarded connections still release pool capacity before rethrowing it.
- Extend existing cancellation tests with chained exceptions at query, transaction callback and cleanup boundaries. Preserve public signatures and ordinary-error behavior. Validate the framework and affected database consumers together.

### Authentication headers on redirects

- Remove Saloon authentication headers and MCP custom headers once a redirect leaves their configured origin, keeping them removed for that chain even if it returns to the original origin. Preserve redirects, absolute endpoints and application-owned PSR hooks; a new Saloon retry attempt or MCP operation applies authentication again. Guzzle already keeps Authorization removed after cross-origin redirects, but the upstream packages forward other credential headers.
- Record header names written through PendingRequest's existing header methods while an authenticator runs, including unchanged values and nested authentication. Keep this state on the pending request and restore the previous recording flag in `finally`. In Sender's existing per-request middleware, remove those headers for another scheme, host or port before PSR hooks and observers run. Ordinary custom headers retain their existing behavior; no opt-in authentication setter, generated-consumer changes or shared middleware API.
- Keep MCP's existing middleware and its list of custom headers. Its closure-local state belongs to one pending request's redirect chain; both HTTP transport operations and OAuth advertisement probes create a fresh request. Preserve MCP protocol/session headers and test credential removal on return hops alongside authentication on a fresh operation.
- Cover physical redirected requests and the response's recorded request, same-origin and cross-origin destinations, absolute endpoints, PSR hook ordering, an unauthenticated return to the original origin and restored authentication on a new retry attempt. Retain concise rationale beside the implementation.

### Cancellation during coroutine error reporting

- Propagate cancellation chains from exception-handler callbacks before the PHP error-log fallback. Reporting may suspend even though startup hooks themselves must not. Do not broaden the startup-hook contract or add guards for suspending hooks.
- Contain cancellation from reporting at the terminal coroutine boundary, while continuing to report task cleanup failures that carry cancellation. Preserve ordinary startup-error continuation and native-only fallback checks. Extend the owned-startup reporting test with real cancellation and throwing cleanup; cover terminal reporting cancellation separately.

## Verification

- Reuse the existing database version/override/PDO-reset and queue-hook cases; add the additive builder grammar/unsupported-path checks and real multi-connection lock contention coverage.
Expand Down
1 change: 0 additions & 1 deletion docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
- Investigate where requiring and directly using a PHP extension would make framework code significantly faster than its current pure-PHP implementation. The framework already declares bundled extensions it depends on, so the question is which hot paths are doing in PHP what a C extension does natively. The worked example is `ext-gmp` for identifier encoding: UUID and ULID string conversion and any base32/base58/base62 short-id work exceed 64 bits, so `ramsey/uuid` and `symfony/uid` convert them digit by digit in PHP, while `gmp_init()`/`gmp_strval()` do arbitrary-base conversion natively — a hand-rolled base-36 UUID conversion measured 14.6 µs against 0.5 µs for the GMP equivalent with byte-identical output. Anything that fits in a 64-bit int (snowflakes, timestamps, counters) needs no extension, and hashing, encryption, and signatures are already C. Measure `Str::uuid()`/`Str::ulid()` and the other candidates before adding a requirement, and weigh each new extension against installation cost.
- Convert untyped `$config->get()` calls across `src/` to the typed getters (`string()`, `integer()`, `float()`, `boolean()`, `array()`) without call-site defaults, for every key that isn't genuinely nullable. Defaults live in the merged config files — declare any key currently defaulted only at a call site in its package's config file as part of the conversion. Typed getters throw `InvalidArgumentException` naming the key on misconfiguration instead of letting a wrong type propagate silently, and give phpstan real return types. Bootstrap code that runs before config merging keeps its call-site defaults. Approved modernization per the Porting Packages policy in `AGENTS.md`; new code already follows the rule.
- Audit unmatched PHPStan inline ignores and global patterns with `reportUnmatchedIgnoredErrors` enabled — currently 196 unmatched inline ignores across 99 files plus 5 unmatched global patterns. Remove only suppressions that no longer match after tracing the underlying code; do not replace correct source with runtime branches or wider types merely to keep static analysis green. Decide as part of the work whether `phpstan.neon.dist` should then set `reportUnmatchedIgnoredErrors: true` permanently, since leaving it `false` lets the suppressions rot again.
- Add PHPStan Eloquent extensions that preserve `Eloquent\Builder<TModel>` for non-passthrough methods forwarded to `Query\Builder`, and expose model named scopes on Eloquent builders and relations. The query-builder mixin currently gives fluent calls the wrong builder type, while named scopes are treated as nonexistent methods; these gaps force scopes to split mutation from return and leave `HasDatabaseNotifications` with `method.notFound` suppressions. This will be the repository's first PHPStan extension, so use Larastan as prior art and wire the extensions into `phpstan.neon.dist` without maintaining duplicate query-method or scope lists in `@method` annotations.
- Create a `hypervel/phpstan` package of PHPStan rules for Hypervel's coroutine and worker-lifetime patterns, so mistakes no longer depend on agent steering and review alone. The rules check how code is written rather than one package's API, so the framework, applications, private packages and third-party packages must all be able to use them. Make it a development-only split package modeled on Larastan, with its own split repository and Packagist entry: require the supported `phpstan/phpstan` range and register the rules in an `extension.neon` listed under `extra.phpstan.includes`, so `phpstan/extension-installer` loads them after `composer require --dev hypervel/phpstan`. Include it from Components' own `phpstan.neon.dist`. Keep package-owned type extensions, such as those in Database, Foundation, Head and Scout, with their packages: they describe those packages' APIs and must load whenever the package is installed, while these rules report errors in the consumer's own code and must be opted into. Test each rule with PHPStan's `RuleTestCase` and fixtures covering both defects and valid code.
- Start with cancellation checks that miss cleanup exception chains around callbacks, listeners, framework I/O and `releaseIdleConnections()`, where `Coroutine::causedByCancellation()` is needed to prevent retries or continued work after cancellation. Detect a `catch (CanceledException)` that rethrows followed by a broader catch that reports, falls back, records an outcome or retries; `instanceof CanceledException` on a throwable other than one just caught as that type; `getPrevious() instanceof CanceledException`; and failure collectors such as `$failure ??= $exception` that should use `Coroutine::preferCancellation()`. Exempt Swoole and Hypervel native waits (channels, `WaitGroup`, `Coroutine::join()` and `Waiter`) only when they are the only work in the `try` block; other valid exact checks use `@phpstan-ignore` with a reason. Use the cancellation regression cases to assess detection and false positives, and preserve valid native-only checks, cleanup-error reporting and foreign-cancellation boundaries. Typed domain catches that can receive a wrapped cancellation, failure observers dispatched before a rethrow and ownership decisions remain review responsibilities rather than being inferred automatically.
- Then turn the coroutine-safety and worker-lifetime rules in `AGENTS.md` into checks. PHPStan rules for detection with type information belong in this package; consider Rector in dry-run mode or another linter only for checks PHPStan cannot express. Start with property writes outside the constructor on container-resolved classes (auto-singletons and singletons) that are not `Transient`, scoped, or execution-scoped, since per-request data stored there leaks to concurrent requests in the same worker. Intentional worker-lifetime state such as manager driver caches, registries, and resolved configuration needs an explicit way to opt out, so measure false positives across `src/` before choosing that mechanism. Then evaluate other patterns: static property writes outside boot code and `flushState()`; static caches keyed by request-derived values; request state captured in constructors of classes that are not execution-scoped; `Config::set()` during request handling; child coroutines started inside `DB::transaction()`, which borrow their own pooled connection and therefore run outside the transaction; and raw `sleep()`/`usleep()` in source instead of `Sleep`.
Expand Down
8 changes: 8 additions & 0 deletions src/broadcasting/src/Broadcasters/Broadcaster.php
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,14 @@ public static function formatChannelsUsing(?Closure $callback): void
static::$channelFormatter = $callback;
}

/**
* Get the registered outgoing channel formatter.
*/
public static function channelFormatter(): ?Closure
{
return static::$channelFormatter;
}

/**
* Register the incoming channel authorizer.
*
Expand Down
17 changes: 10 additions & 7 deletions src/coroutine/src/Coroutine.php
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,6 @@ private static function runChild(callable $callable, array $context): void
foreach (static::$afterCreatedCallbacks as $callback) {
try {
$callback();
} catch (CanceledException $exception) {
throw $exception;
} catch (Throwable $throwable) {
static::printLog($throwable);
}
Expand Down Expand Up @@ -294,7 +292,7 @@ public static function reportExceptions(bool $report = true): void
/**
* Report an exception through the exception handler.
*
* @throws CanceledException When the failure or reporting path is canceled
* @throws Throwable When cancellation or its cleanup failure must propagate
*/
protected static function printLog(Throwable $throwable): void
{
Expand All @@ -313,9 +311,11 @@ protected static function printLog(Throwable $throwable): void
$container->make(ExceptionHandlerContract::class)
->report($throwable);
}
} catch (CanceledException $exception) {
throw $exception;
} catch (Throwable) {
} catch (Throwable $exception) {
if (static::causedByCancellation($exception)) {
throw $exception;
}

try {
error_log((string) $throwable);
} catch (CanceledException $exception) {
Expand All @@ -336,7 +336,10 @@ protected static function reportUncaught(Throwable $throwable): void

try {
static::printLog($throwable);
} catch (CanceledException) {
} catch (Throwable $exception) {
if (! static::causedByCancellation($exception)) {
throw $exception;
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Spatie's data-specific `From*` attributes are not included. Use Hypervel's conte

With `withoutOptionalValues()`, a missing `Optional` property that cannot hold `null` keeps `Optional`, where Spatie leaves it uninitialized. A missing nullable `Optional` property receives `null`, as in Spatie.

Spatie's `UnserializeCast` is not included because it unserializes property input, which may come from a request, without restricting the classes it creates. Write a custom cast that passes `allowed_classes` to `unserialize()` when a property holds trusted serialized values.
`UnserializeCast` restores no objects unless their classes are listed in `allowedClasses`, where Spatie allows every class by default. List the classes your trusted serialized values contain, including nested ones, or pass `true`. See [unserializing values](https://hypervel.org/docs/data-objects#unserializing-values).

Named `collect*` methods receive the source's own array, collection, or paginator shape after its values have been converted to data objects, rather than the original source values. An Eloquent collection source is provided as a base `Hypervel\Support\Collection`. When you pass an explicit `$into` target, the method's declared return type must also match that target.

Expand Down
1 change: 1 addition & 0 deletions src/data/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"hypervel/console": "^0.4",
"hypervel/container": "^0.4",
"hypervel/contracts": "^0.4",
"hypervel/coroutine": "^0.4",
"hypervel/database": "^0.4",
"hypervel/foundation": "^0.4",
"hypervel/http": "^0.4",
Expand Down
1 change: 0 additions & 1 deletion src/data/src/Casts/Cast.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
use Hypervel\Data\Support\Creation\CreationContext;
use Hypervel\Data\Support\DataProperty;

// REMOVED: UnserializeCast accepted serialized request input; use a custom Cast for trusted formats.
interface Cast
{
/**
Expand Down
48 changes: 48 additions & 0 deletions src/data/src/Casts/UnserializeCast.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

namespace Hypervel\Data\Casts;

use Hypervel\Coroutine\Coroutine;
use Hypervel\Data\Support\Creation\CreationContext;
use Hypervel\Data\Support\DataProperty;
use Throwable;

class UnserializeCast implements Cast
{
/**
* Create an unserialize cast.
*
* @param array<int, class-string>|bool $allowedClasses
*/
public function __construct(
private readonly bool $failSilently = false,
// Spatie allows every class by default. Hypervel restores no objects unless their
// classes are allowed, so unserializing runs no __wakeup() or __destruct() by default.
private readonly array|bool $allowedClasses = false,
) {
}

/**
* Cast a property value by unserializing it.
*/
public function cast(DataProperty $property, mixed $value, array $properties, CreationContext $context): mixed
{
/** @var ?string $value */
if ($value === null) {
return null;
}

try {
return unserialize($value, ['allowed_classes' => $this->allowedClasses]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
} catch (Throwable $e) {
// The restore hooks of allowed classes can suspend, so cancellation is not a failed cast.
if ($this->failSilently && ! Coroutine::causedByCancellation($e)) {
return Uncastable::create();
}

throw $e;
}
}
}
33 changes: 33 additions & 0 deletions src/docs/data-objects.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
- [Abstract Data Objects](#abstract-data-objects)
- [Backed Enums](#backed-enums)
- [Casts and Transformers](#casts-and-transformers)
- [Unserializing Values](#unserializing-values)
- [Custom Casts](#custom-casts)
- [Custom Transformers](#custom-transformers)
- [Global Casts and Transformers](#global-casts-and-transformers)
Expand Down Expand Up @@ -898,6 +899,38 @@ class InvoiceData extends Data
}
```

<a name="unserializing-values"></a>
### Unserializing Values

The `UnserializeCast` runs PHP's `unserialize` function on a value. This is useful when you store serialized values yourself, for example in a database column or a cache:

```php
use Hypervel\Data\Attributes\WithCast;
use Hypervel\Data\Casts\UnserializeCast;

class SongData extends Data
{
public function __construct(
public string $title,
#[WithCast(UnserializeCast::class, allowedClasses: [SongMetadata::class])]
public SongMetadata $metadata,
) {
}
}
```

The `allowedClasses` argument lists the classes that may be created while unserializing. Any other class becomes a `__PHP_Incomplete_Class` object, so a property typed as that class fails with a `TypeError`. This also applies to nested objects: when `SongMetadata` holds a `CarbonImmutable` date, `CarbonImmutable` must be listed as well. By default, no classes are allowed, so only scalar values, arrays, and enum cases are restored. You may pass `true` to allow every class.

> [!WARNING]
> Only unserialize trusted data. Class restrictions do not make user-controlled serialized data safe. For user input, use JSON and validate the decoded values.

When the value cannot be unserialized, an exception is thrown. You may make the cast fail silently instead, which leaves the value to the built-in conversion:

```php
#[WithCast(UnserializeCast::class, failSilently: true, allowedClasses: [SongMetadata::class])]
public SongMetadata $metadata;
```

<a name="custom-casts"></a>
### Custom Casts

Expand Down
Loading
Loading