Skip to content
Closed
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
501 changes: 501 additions & 0 deletions docs/plans/2026-08-11-0932-json-correctness-and-package-metadata.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion src/collections/src/Arr.php
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,8 @@ public static function from(mixed $items): array
$items instanceof Arrayable => $items->toArray(),
$items instanceof WeakMap => iterator_to_array($items, false),
$items instanceof Traversable => iterator_to_array($items),
$items instanceof Jsonable => json_decode($items->toJson(), true),
// Support depends on Collections, so this native depth cannot reference Support\Json; 513 reads 512 containers.
$items instanceof Jsonable => json_decode($items->toJson(), true, 513),
$items instanceof JsonSerializable => (array) $items->jsonSerialize(),
is_object($items) => (array) $items, // @phpstan-ignore function.alreadyNarrowedType
default => throw new InvalidArgumentException('Items cannot be represented by a scalar value.'),
Expand Down
6 changes: 4 additions & 2 deletions src/collections/src/Traits/EnumeratesValues.php
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,9 @@ public static function times(int $number, ?callable $callback = null, mixed ...$
*
* @return static<TKey, TValue>
*/
public static function fromJson(string $json, int $depth = 512, int $flags = 0, mixed ...$args): static
public static function fromJson(string $json, int $depth = 513, int $flags = 0, mixed ...$args): static
{
// Support depends on Collections, so this native depth cannot reference Support\Json; 513 reads 512 containers.
return new static(json_decode($json, true, $depth, $flags), ...$args);
}

Expand Down Expand Up @@ -934,7 +935,8 @@ public function jsonSerialize(): array
return array_map(function ($value) {
return match (true) {
$value instanceof JsonSerializable => $value->jsonSerialize(),
$value instanceof Jsonable => json_decode($value->toJson(), true),
// Support depends on Collections, so this native depth cannot reference Support\Json; 513 reads 512 containers.
$value instanceof Jsonable => json_decode($value->toJson(), true, 513),
$value instanceof Arrayable => $value->toArray(),
default => $value,
};
Expand Down
3 changes: 2 additions & 1 deletion src/concurrency/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
"hypervel/coroutine": "^0.4",
"hypervel/process": "^0.4",
"hypervel/support": "^0.4",
"nesbot/carbon": "^3.13.1"
"nesbot/carbon": "^3.13.1",
"symfony/console": "^8.1"
},
"config": {
"sort-packages": true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

declare(strict_types=1);

namespace Hypervel\Foundation\Console;
namespace Hypervel\Concurrency\Console;

use Error;
use Exception;
Expand Down Expand Up @@ -53,6 +53,7 @@ public function handle(): int

if ($parameters !== null) {
// Named arguments must survive JSON without changing types or nested state.
// This subtree is one container shallower than the envelope decoded at native depth 513.
$encodedParameters = json_encode($parameters, self::JSON_FLAGS);

if (json_decode($encodedParameters, true, 512, JSON_THROW_ON_ERROR) !== $parameters) {
Expand Down
66 changes: 1 addition & 65 deletions src/concurrency/src/ProcessDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@
use Hypervel\Support\Arr;
use Hypervel\Support\Defer\DeferredCallback;
use Laravel\SerializableClosure\SerializableClosure;
use RuntimeException;
use Throwable;

use function Hypervel\Support\defer;

Expand Down Expand Up @@ -55,69 +53,7 @@ public function run(Closure|array $tasks, CarbonInterval|int|null $timeout = nul
throw new Exception('Concurrent process failed with exit code [' . $result->exitCode() . ']. Message: ' . $result->errorOutput());
}

$output = $result->output();

if (($position = strpos($output, "\x1f\x8b")) !== false) {
$output = substr($output, 0, $position);
}

$payload = json_decode($output, true, 512, JSON_THROW_ON_ERROR);

if (! is_array($payload)
|| ! array_key_exists('successful', $payload)
|| ! is_bool($payload['successful'])) {
throw new RuntimeException('Invalid concurrent process response envelope.');
}

/** @var array{
* successful: bool,
* result?: string,
* exception?: class-string<Throwable>,
* message?: string,
* parameters?: array<string, mixed>
* } $payload
*/
if ($payload['successful'] === false) {
if ((array_key_exists('exception', $payload) && ! is_string($payload['exception']))
|| (array_key_exists('message', $payload) && ! is_string($payload['message']))
|| (array_key_exists('parameters', $payload) && ! is_array($payload['parameters']))) {
throw new RuntimeException('Invalid concurrent process response envelope.');
}

$exceptionClass = $payload['exception'] ?? RuntimeException::class;
$message = $payload['message'] ?? 'Serialized closure execution failed.';
$parameters = $payload['parameters'] ?? ['message' => $message];

try {
$exception = new $exceptionClass(...$parameters);
} catch (Throwable $constructionException) {
throw new RuntimeException($message, previous: $constructionException);
}

if (! $exception instanceof Throwable) {
throw new RuntimeException($message);
}

throw $exception;
}

$encodedResult = $payload['result'] ?? null;
$serializedResult = is_string($encodedResult)
? base64_decode($encodedResult, true)
: false;

if ($serializedResult === false) {
throw new RuntimeException('Unable to decode the concurrent process result.');
}

// Malformed payloads warn and return false, which is also a valid serialized result.
$unserializedResult = @unserialize($serializedResult);

if ($unserializedResult === false && $serializedResult !== serialize(false)) {
throw new RuntimeException('Unable to decode the concurrent process result.');
}

return [$key => $unserializedResult];
return [$key => SerializedClosureResult::decode($result->output())];
})->all();
}

Expand Down
92 changes: 92 additions & 0 deletions src/concurrency/src/SerializedClosureResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

declare(strict_types=1);

namespace Hypervel\Concurrency;

use Hypervel\Support\Json;
use RuntimeException;
use Throwable;

/**
* Decode serialized-closure responses produced by the framework subprocess command.
*
* The subprocess stream may also contain output from the application task, so the
* response envelope is validated before use. Task results may contain objects, so
* successful responses require unrestricted unserialization. Callers must never
* pass external input to this decoder.
*
* @internal
*/
class SerializedClosureResult
{
/**
* Return the unserialized result or throw the reconstructed remote exception.
*
* @throws Throwable
*/
public static function decode(string $output): mixed
{
if (($position = strpos($output, "\x1f\x8b")) !== false) {
$output = substr($output, 0, $position);
}

$payload = Json::decode($output);

if (! is_array($payload)
|| ! array_key_exists('successful', $payload)
|| ! is_bool($payload['successful'])) {
throw new RuntimeException('Invalid serialized closure response envelope.');
}

/** @var array{
* successful: bool,
* result?: string,
* exception?: class-string<Throwable>,
* message?: string,
* parameters?: array<string, mixed>
* } $payload
*/
if ($payload['successful'] === false) {
if ((array_key_exists('exception', $payload) && ! is_string($payload['exception']))
|| (array_key_exists('message', $payload) && ! is_string($payload['message']))
|| (array_key_exists('parameters', $payload) && ! is_array($payload['parameters']))) {
throw new RuntimeException('Invalid serialized closure response envelope.');
}

$exceptionClass = $payload['exception'] ?? RuntimeException::class;
$message = $payload['message'] ?? 'Serialized closure execution failed.';
$parameters = $payload['parameters'] ?? ['message' => $message];

try {
if (! is_a($exceptionClass, Throwable::class, true)) {
throw new RuntimeException("The transported exception class [{$exceptionClass}] is not an available Throwable.");
}

$exception = new $exceptionClass(...$parameters);
} catch (Throwable $constructionException) {
throw new RuntimeException($message, previous: $constructionException);
}

throw $exception;
}

$encodedResult = $payload['result'] ?? null;
$serializedResult = is_string($encodedResult)
? base64_decode($encodedResult, true)
: false;

if ($serializedResult === false) {
throw new RuntimeException('Unable to decode the serialized closure result.');
}

// Malformed payloads warn and return false, which is also a valid serialized result.
$unserializedResult = @unserialize($serializedResult);

if ($unserializedResult === false && $serializedResult !== serialize(false)) {
throw new RuntimeException('Unable to decode the serialized closure result.');
}

return $unserializedResult;
}
}
2 changes: 1 addition & 1 deletion src/database/src/Console/ShowCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ protected function display(array $data): void
*/
protected function displayJson(array $data): void
{
$this->output->writeln(json_encode($data));
$this->output->writeln(json_encode($data, JSON_THROW_ON_ERROR));
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/database/src/Console/TableCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ protected function display(array $data): void
*/
protected function displayJson(array $data): void
{
$this->output->writeln(json_encode($data));
$this->output->writeln(json_encode($data, JSON_THROW_ON_ERROR));
}

/**
Expand Down
16 changes: 12 additions & 4 deletions src/database/src/Eloquent/Casts/AsArrayObject.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use Hypervel\Contracts\Database\Eloquent\Castable;
use Hypervel\Contracts\Database\Eloquent\CastsAttributes;
use Hypervel\Database\Eloquent\JsonEncodingException;
use Hypervel\Database\Eloquent\Model;

class AsArrayObject implements Castable
{
Expand All @@ -17,7 +19,7 @@ class AsArrayObject implements Castable
public static function castUsing(array $arguments): CastsAttributes
{
return new class implements CastsAttributes {
public function get(mixed $model, string $key, mixed $value, array $attributes): ?ArrayObject
public function get(Model $model, string $key, mixed $value, array $attributes): ?ArrayObject
{
if (! isset($attributes[$key])) {
return null;
Expand All @@ -28,12 +30,18 @@ public function get(mixed $model, string $key, mixed $value, array $attributes):
return is_array($data) ? new ArrayObject($data, ArrayObject::ARRAY_AS_PROPS) : null;
}

public function set(mixed $model, string $key, mixed $value, array $attributes): array
public function set(Model $model, string $key, mixed $value, array $attributes): array
{
return [$key => Json::encode($value)];
$encoded = Json::encode($value);

if ($encoded === false) {
throw JsonEncodingException::forAttribute($model, $key, json_last_error_msg());
}

return [$key => $encoded];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

public function serialize(mixed $model, string $key, mixed $value, array $attributes): array
public function serialize(Model $model, string $key, mixed $value, array $attributes): array
{
return $value->getArrayCopy();
}
Expand Down
14 changes: 11 additions & 3 deletions src/database/src/Eloquent/Casts/AsCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use Hypervel\Contracts\Database\Eloquent\Castable;
use Hypervel\Contracts\Database\Eloquent\CastsAttributes;
use Hypervel\Database\Eloquent\JsonEncodingException;
use Hypervel\Database\Eloquent\Model;
use Hypervel\Support\Collection;
use Hypervel\Support\Str;
use InvalidArgumentException;
Expand All @@ -25,7 +27,7 @@ public function __construct(protected array $arguments)
$this->arguments = array_pad(array_values($this->arguments), 2, '');
}

public function get(mixed $model, string $key, mixed $value, array $attributes): ?Collection
public function get(Model $model, string $key, mixed $value, array $attributes): ?Collection
{
if (! isset($attributes[$key])) {
return null;
Expand Down Expand Up @@ -58,9 +60,15 @@ public function get(mixed $model, string $key, mixed $value, array $attributes):
: $instance->mapInto($this->arguments[1][0]);
}

public function set(mixed $model, string $key, mixed $value, array $attributes): array
public function set(Model $model, string $key, mixed $value, array $attributes): array
{
return [$key => Json::encode($value)];
$encoded = Json::encode($value);

if ($encoded === false) {
throw JsonEncodingException::forAttribute($model, $key, json_last_error_msg());
}

return [$key => $encoded];
}
};
}
Expand Down
15 changes: 12 additions & 3 deletions src/database/src/Eloquent/Casts/AsDataObject.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Hypervel\Database\Eloquent\Casts;

use Hypervel\Contracts\Database\Eloquent\CastsAttributes;
use Hypervel\Database\Eloquent\JsonEncodingException;
use Hypervel\Database\Eloquent\Model;
use Hypervel\Support\DataObject;
use InvalidArgumentException;
Expand Down Expand Up @@ -34,7 +35,9 @@ public function get(
mixed $value,
array $attributes,
): ?DataObject {
if (! $data = json_decode((string) $value, true)) {
$data = Json::decode((string) $value);

if (! is_array($data)) {
return null;
}

Expand All @@ -54,8 +57,14 @@ public function set(
string $key,
mixed $value,
array $attributes,
): string {
return json_encode($value);
): array {
$encoded = Json::encode($value);

if ($encoded === false) {
throw JsonEncodingException::forAttribute($model, $key, json_last_error_msg());
}

return [$key => $encoded];
}

/**
Expand Down
Loading