Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d6aef41
Match logoutOtherDevices return contract
binaryfire Oct 26, 2026
37d05a1
Preserve numeric keys in JSON translations
binaryfire Oct 26, 2026
ee86379
Cover literal dotted keys when touching array cache entries
binaryfire Oct 26, 2026
d34b2f0
Cover numeric keys in memoized tagged cache reads
binaryfire Oct 26, 2026
02b67a3
Cover literal braces in URLs without template parameters
binaryfire Oct 26, 2026
cd13939
Cover boolean values in database validation rules
binaryfire Oct 26, 2026
966e977
Cover short-circuiting iterable first lookups
binaryfire Oct 26, 2026
8a56bc7
Assert disabled factory relationships remain unset
binaryfire Oct 26, 2026
73de638
Cover enum IDs in Eloquent findOrFail and findOr
binaryfire Oct 26, 2026
b714a6e
Reconcile Eloquent collection findOrFail coverage
binaryfire Oct 26, 2026
734ec07
Use an ASCII fallback for download filenames
binaryfire Oct 26, 2026
6a30463
Cover multiline server-sent event messages
binaryfire Oct 26, 2026
df4a56c
Release mailables retained by email callbacks
binaryfire Oct 26, 2026
81a7fc1
Cover failover mail transport retry configuration
binaryfire Oct 26, 2026
b1d5b1a
Release MorphTo relations retained by soft-delete callbacks
binaryfire Oct 26, 2026
2a08879
Cover non-date values in date comparison validation
binaryfire Oct 26, 2026
59a7566
Compare encrypted object and collection casts by value
binaryfire Oct 26, 2026
11ba680
Allow redirect responses wherever Responsable is accepted
binaryfire Oct 26, 2026
b8396f5
Distinguish skipped migrations in migrate:status
binaryfire Oct 26, 2026
57def7e
Cover collection bounds in collection range filters
binaryfire Oct 26, 2026
50060f7
Count interrupted queue jobs without confusing concurrent retries
binaryfire Oct 26, 2026
25b33d8
Keep crash-marker cleanup retryable across job handoffs
binaryfire Oct 26, 2026
fa8fc94
Match failed-job queue names exactly
binaryfire Oct 26, 2026
e689201
Preserve JSON object shape when checking cast attributes for changes
binaryfire Oct 26, 2026
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
12 changes: 4 additions & 8 deletions src/auth/src/SessionGuard.php
Original file line number Diff line number Diff line change
Expand Up @@ -661,13 +661,13 @@ protected function cycleRememberToken(AuthenticatableContract $user): void
*
* @throws AuthenticationException
*/
public function logoutOtherDevices(#[SensitiveParameter] string $password): ?AuthenticatableContract
public function logoutOtherDevices(#[SensitiveParameter] string $password): void
{
if (! $this->user()) {
return null;
return;
}

$result = $this->rehashUserPasswordForDeviceLogout($password);
$this->rehashUserPasswordForDeviceLogout($password);

$this->session->put(
'password_hash_' . $this->name,
Expand All @@ -680,16 +680,14 @@ public function logoutOtherDevices(#[SensitiveParameter] string $password): ?Aut
}

$this->fireOtherDeviceLogoutEvent($this->user());

return $result;
}

/**
* Rehash the current user's password for logging out other devices via AuthenticateSession.
*
* @throws InvalidArgumentException
*/
protected function rehashUserPasswordForDeviceLogout(#[SensitiveParameter] string $password): ?AuthenticatableContract
protected function rehashUserPasswordForDeviceLogout(#[SensitiveParameter] string $password): void
{
$user = $this->user();

Expand All @@ -702,8 +700,6 @@ protected function rehashUserPasswordForDeviceLogout(#[SensitiveParameter] strin
['password' => $password],
force: true
);

return $user;
}

/**
Expand Down
24 changes: 24 additions & 0 deletions src/contracts/src/Queue/JobLifecycleCallbacks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Hypervel\Contracts\Queue;

use Closure;

interface JobLifecycleCallbacks
{
/**
* Register a callback to run before the job becomes available for another attempt.
*
* The release must not proceed if a callback throws.
*/
public function beforeReleasing(Closure $callback): static;

/**
* Register a callback to run before the job is recorded as failed.
*
* The failure must not be published if a callback throws.
*/
public function beforeFailing(Closure $callback): static;
}
16 changes: 10 additions & 6 deletions src/database/src/Console/Migrations/StatusCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,16 +100,20 @@ public function handle(): int
*/
protected function getStatusFor(array $ran, array $batches): Collection
{
$hasPending = false;

return (new Collection($this->getAllMigrationFiles()))
->map(function ($migration) use ($ran, $batches) {
->map(function (string $migration) use ($ran, $batches, &$hasPending): array {
$migrationName = $this->migrator->getMigrationName($migration);

$status = in_array($migrationName, $ran)
? '<fg=green;options=bold>Ran</>'
: '<fg=yellow;options=bold>Pending</>';
if (in_array($migrationName, $ran, true)) {
$status = '[' . $batches[$migrationName] . '] <fg=green;options=bold>Ran</>';
} elseif (! $hasPending && $this->migrator->shouldSkip($migration)) {
$status = '<fg=gray;options=bold>Skipped</>';
} else {
$hasPending = true;

if (in_array($migrationName, $ran)) {
$status = '[' . $batches[$migrationName] . '] ' . $status;
$status = '<fg=yellow;options=bold>Pending</>';
}

return [$migrationName, $status];
Expand Down
34 changes: 25 additions & 9 deletions src/database/src/Eloquent/Concerns/HasAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -2296,15 +2296,7 @@ public function originalIsEquivalent(string $key): bool
=== $this->fromDateTime($original);
}
if ($this->hasCast($key, ['object', 'collection'])) {
$current = $this->fromJson($attribute);

try {
$original = $this->fromJson($original);
} catch (JsonException) {
return false;
}

return $current === $original;
return $this->jsonIsEquivalent($attribute, $original, $this->hasCast($key, ['object']));
}
if ($this->hasCast($key, ['real', 'float', 'double'])) {
if ($original === null) {
Expand All @@ -2316,6 +2308,13 @@ public function originalIsEquivalent(string $key): bool
if ($this->isEncryptedCastable($key) && ! empty(static::currentEncrypter()->getPreviousKeys())) {
return false;
}
if ($original !== null && $this->hasCast($key, ['encrypted:object', 'encrypted:collection'])) {
return $this->jsonIsEquivalent(
$this->fromEncryptedString($attribute),
$this->fromEncryptedString($original),
$this->hasCast($key, ['encrypted:object']),
);
}
if ($this->hasCast($key, static::$primitiveCastTypes)) {
$current = $this->castAttribute($key, $attribute);

Expand Down Expand Up @@ -2364,6 +2363,23 @@ public function originalIsEquivalent(string $key): bool
&& strcmp((string) $attribute, (string) $original) === 0;
}

/**
* Compare JSON values using the structure exposed by their cast.
*/
protected function jsonIsEquivalent(?string $attribute, ?string $original, bool $asObject): bool
{
$current = $this->fromJson($attribute, $asObject);

try {
$original = $this->fromJson($original, $asObject);
} catch (JsonException) {
return false;
}

// Object casts preserve nested objects versus arrays as well as scalar types.
return $asObject ? serialize($current) === serialize($original) : $current === $original;
}

/**
* Transform a raw model value using mutators, casts, etc.
*/
Expand Down
6 changes: 3 additions & 3 deletions src/database/src/Eloquent/Relations/MorphTo.php
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@ public function constrain(array $callbacks): static
*/
public function withTrashed(): static
{
$callback = fn ($query) => $query->hasMacro('withTrashed') ? $query->withTrashed() : $query;
$callback = static fn (Builder $query): mixed => $query->hasMacro('withTrashed') ? $query->withTrashed() : $query;

$this->macroBuffer[] = [
'method' => 'when',
Expand All @@ -395,7 +395,7 @@ public function withTrashed(): static
*/
public function withoutTrashed(): static
{
$callback = fn ($query) => $query->hasMacro('withoutTrashed') ? $query->withoutTrashed() : $query;
$callback = static fn (Builder $query): mixed => $query->hasMacro('withoutTrashed') ? $query->withoutTrashed() : $query;

$this->macroBuffer[] = [
'method' => 'when',
Expand All @@ -412,7 +412,7 @@ public function withoutTrashed(): static
*/
public function onlyTrashed(): static
{
$callback = fn ($query) => $query->hasMacro('onlyTrashed') ? $query->onlyTrashed() : $query;
$callback = static fn (Builder $query): mixed => $query->hasMacro('onlyTrashed') ? $query->onlyTrashed() : $query;

$this->macroBuffer[] = [
'method' => 'when',
Expand Down
10 changes: 10 additions & 0 deletions src/database/src/Migrations/Migrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,16 @@ protected function resolvePath(string $path): object
return new $class;
}

/**
* Determine if the migration at the given path should be skipped.
*/
public function shouldSkip(string $path): bool
{
$migration = $this->resolvePath($path);

return $migration instanceof Migration && ! $migration->shouldRun();
}

/**
* Get the distinct connections declared by the migrations at the given paths.
*
Expand Down
2 changes: 2 additions & 0 deletions src/docs/mail.md
Original file line number Diff line number Diff line change
Expand Up @@ -1253,6 +1253,8 @@ Queue the mailable inside the transaction. Hypervel will store its job in the [t
<a name="queued-email-failures"></a>
#### Queued Email Failures

You may add the `Hypervel\Queue\Attributes\DeleteWhenMissingModels` attribute to a mailable to discard it when a serialized model no longer exists. To mark a mailable as failed when it times out, add the `Hypervel\Queue\Attributes\FailOnTimeout` attribute. You may also enable these behaviors by setting the mailable's public `$deleteWhenMissingModels` or `$failOnTimeout` property to `true`.

When a queued email fails, the `failed` method on the queued mailable class will be invoked if it has been defined. The `Throwable` instance that caused the queued email to fail will be passed to the `failed` method:

```php
Expand Down
2 changes: 2 additions & 0 deletions src/docs/migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,8 @@ If you would like to see which migrations have already run and which are still p
php artisan migrate:status
```

Before the first pending migration, migrations whose `shouldRun` method returns `false` are shown as skipped. Later unapplied migrations remain pending because their conditions may depend on earlier migrations.

If you provide the `--step` option to the `migrate` command, the command will run each migration as its own batch, allowing you to roll back individual migrations later using the `migrate:rollback` command:

```shell
Expand Down
2 changes: 2 additions & 0 deletions src/docs/porting-from-laravel.md
Original file line number Diff line number Diff line change
Expand Up @@ -863,6 +863,8 @@ Queue connections include `database`, `redis`, `sqs`, `beanstalkd`, `failover`,

Hypervel stores job batches in a relational database. Laravel's DynamoDB batch repository and DynamoDB failed-job provider are not available. Supported failed-job drivers are `database`, `database-uuids`, `file`, and `null`. See the [queue documentation](/docs/{{version}}/queues) for connection and worker configuration.

Custom queue drivers whose job classes implement `Hypervel\Contracts\Queue\Job` directly must also implement `Hypervel\Contracts\Queue\JobLifecycleCallbacks` to run jobs that use `CountCrashesAsExceptions`; extending `Hypervel\Queue\Jobs\Job` provides it. See [counting worker crashes](/docs/{{version}}/queues#counting-worker-crashes) for the callback requirements.

With `Worker::$killOnTimeout = false`, a timeout interrupts job code with `Swoole\Coroutine\CanceledException`; catching `TimeoutExceededException` inside the job does not catch it. Use `finally` for cleanup and rethrow cancellation if caught. Failure callbacks and worker reporting still receive `TimeoutExceededException`. See [worker timeouts](/docs/{{version}}/queues#worker-timeouts).

If you extend Horizon's `RedisQueue`, do not rely on `push()` calling your `pushRaw()` override. Override the protected `preparePayloadForPush()` method to prepare normal pushes, or `handlePayloadPushed()` to run code after Redis accepts the job. Direct calls to `pushRaw()` remain supported.
Expand Down
23 changes: 22 additions & 1 deletion src/docs/queues.md
Original file line number Diff line number Diff line change
Expand Up @@ -1957,6 +1957,9 @@ In this example, the job is released for ten seconds if the application is unabl

`MaxExceptions` tracks failures using an atomic counter on your default cache store. If that store uses Redis with PhpRedis serialization, configure counters as described in the [cache increment and decrement documentation](/docs/{{version}}/cache#incrementing-decrementing-values).

<a name="counting-worker-crashes"></a>
#### Counting Worker Crashes

By default, an attempt that ends because the worker process crashed or was killed, such as when it runs out of memory, does not count towards the job's maximum number of exceptions. If you would like these attempts to count as an exception, you may add the `CountCrashesAsExceptions` attribute to your job class:

```php
Expand All @@ -1973,7 +1976,17 @@ class ProcessPodcast implements ShouldQueue
}
```

When this attribute is present, the worker stores a marker in your application's cache while the job is processing. If the marker still exists when the job is next attempted, the previous attempt is counted as an exception.
When this attribute is present, the worker stores a marker in the cache while the job is processing. If the marker still exists when the job is next attempted, the previous attempt is counted as an exception. By default, workers use your default cache store, which must be shared by your workers and keep its data when they restart. Markers expire after 24 hours; an expired or removed marker cannot identify an interrupted attempt.

You may also use this attribute on queued listeners, mailables, and notifications, or set their public `$countCrashesAsExceptions` property to `true`. A maximum exception count must be configured for crash counting to take effect.

When a worker processes jobs concurrently, a crash or a [timeout that stops the worker](#worker-timeouts) (the default) can interrupt several jobs. Each interrupted attempt with this setting counts towards its own exception limit; an already-counted timeout is not counted again.

The worker clears the marker before releasing a job or recording it as failed, so immediate retries are not mistaken for crashes. A worker death between clearing the marker and completing that handoff cannot be detected by the marker.

Custom queue drivers whose job objects extend `Hypervel\Queue\Jobs\Job` inherit the required cleanup callbacks. Their `release()` method must call `parent::release()` before making the job available again, and any `fail()` override must preserve the parent's callback ordering before recording or publishing a failure.

If your job objects implement `Hypervel\Contracts\Queue\Job` directly, they must also implement `Hypervel\Contracts\Queue\JobLifecycleCallbacks` to support crash counting. Run the registered `beforeReleasing` callbacks before making a job available again, and the `beforeFailing` callbacks before publishing a failure. If a callback throws, let the exception propagate without completing the release or failure. A job requesting crash counting without this capability fails before execution; jobs that do not request it are unaffected.

<a name="stopping-retries-by-exception"></a>
#### Stopping Retries by Exception
Expand Down Expand Up @@ -2819,6 +2832,8 @@ The command also reports worker stop reasons, such as reaching the memory limit.
php artisan queue:work --json
```

The JSON output includes connection and queue names when queues pause or resume and when workers stop.

Remember, queue workers are long-lived processes and store the booted application state in memory. As a result, they will not notice changes in your code base after they have been started. So, during your deployment process, be sure to [restart your queue workers](#queue-workers-and-deployment). In addition, remember that any static state created or modified by your application will not be automatically reset between jobs. Request or job specific state should be stored in `CoroutineContext` instead of static properties or mutable singletons.

Alternatively, you may run the `queue:listen` command. When using the `queue:listen` command, you don't have to manually restart the worker when you want to reload your updated code or reset the application state; however, this command is significantly less efficient than the `queue:work` command:
Expand Down Expand Up @@ -3397,6 +3412,12 @@ To return the failed jobs as JSON, pass the `--json` option:
php artisan queue:failed --json
```

To list failed jobs for a specific queue, use the `--queue` option:

```shell
php artisan queue:failed --queue=emails
```

The `queue:failed` command will list the job ID, connection, queue, failure time, and other information about the job. The job ID may be used to retry the failed job. For instance, to retry a failed job that has an ID of `ce7bb17c-cdd8-41f0-a8ec-7b4fef4e5ece`, issue the following command:

```shell
Expand Down
2 changes: 2 additions & 0 deletions src/docs/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,8 @@ Route::get('/dashboard', function () {
});
```

Redirect responses implement `Hypervel\Contracts\Support\Responsable`, so you may return them from routes or controller methods that declare this return type.

Sometimes you may wish to redirect the user to their previous location, such as when a submitted form is invalid. You may do so by using the global `back` helper function. Since this feature utilizes the [session](/docs/{{version}}/session), make sure the route calling the `back` function is using the `web` middleware group:

```php
Expand Down
5 changes: 5 additions & 0 deletions src/events/src/CallQueuedListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ class CallQueuedListener implements ShouldQueue
*/
public bool $failOnTimeout = false;

/**
* Indicates if worker crashes should count towards the maximum exceptions.
*/
public bool $countCrashesAsExceptions = false;

/**
* Indicates if the job should be encrypted.
*/
Expand Down
2 changes: 2 additions & 0 deletions src/events/src/Dispatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
use Hypervel\Database\DatabaseTransactionsManager;
use Hypervel\Queue\Attributes\Backoff;
use Hypervel\Queue\Attributes\Connection;
use Hypervel\Queue\Attributes\CountCrashesAsExceptions;
use Hypervel\Queue\Attributes\DebounceFor;
use Hypervel\Queue\Attributes\Delay;
use Hypervel\Queue\Attributes\DeleteWhenMissingModels;
Expand Down Expand Up @@ -1058,6 +1059,7 @@ protected function propagateListenerOptions(mixed $listener, CallQueuedListener
$job->shouldBeEncrypted = $listener instanceof ShouldBeEncrypted;
$job->timeout = $this->getAttributeValue($listener, Timeout::class, 'timeout');
$job->failOnTimeout = $this->getAttributeValue($listener, FailOnTimeout::class, 'failOnTimeout') ?? false;
$job->countCrashesAsExceptions = $this->getAttributeValue($listener, CountCrashesAsExceptions::class, 'countCrashesAsExceptions') ?? false;
$job->deleteWhenMissingModels = $this->getAttributeValue($listener, DeleteWhenMissingModels::class, 'deleteWhenMissingModels') ?? false;
$job->tries = method_exists($listener, 'tries') ? $listener->tries(...$data) : $this->getAttributeValue($listener, Tries::class, 'tries');
$job->messageGroup = method_exists($listener, 'messageGroup') ? $listener->messageGroup(...$data) : ($listener->messageGroup ?? null);
Expand Down
4 changes: 3 additions & 1 deletion src/filesystem/src/FileResponseBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,8 @@ private function closeStream(mixed $stream, ?Throwable $primaryException = null)
*/
private function fallbackName(string $name): string
{
return str_replace('%', '', Str::ascii($name));
$fallback = str_replace('%', '', Str::ascii($name));

return $fallback !== '' ? $fallback : str_repeat('_', mb_strlen($name));
}
}
14 changes: 13 additions & 1 deletion src/http/src/RedirectResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use BadMethodCallException;
use Hypervel\Contracts\Support\MessageBag as MessageBagContract;
use Hypervel\Contracts\Support\MessageProvider;
use Hypervel\Contracts\Support\Responsable;
use Hypervel\Session\Store as SessionStore;
use Hypervel\Support\MessageBag;
use Hypervel\Support\Str;
Expand All @@ -17,7 +18,7 @@
use Symfony\Component\HttpFoundation\File\UploadedFile as SymfonyUploadedFile;
use Symfony\Component\HttpFoundation\RedirectResponse as BaseRedirectResponse;

class RedirectResponse extends BaseRedirectResponse
class RedirectResponse extends BaseRedirectResponse implements Responsable
{
use ForwardsCalls, ResponseTrait, Macroable {
Macroable::__call as macroCall;
Expand Down Expand Up @@ -207,6 +208,17 @@ public function setSession(SessionStore $session): static
return $this;
}

/**
* Create an HTTP response that represents the object.
*
* @param Request $request
* @return $this
*/
public function toResponse(mixed $request): static
{
return $this;
}

/**
* Flush all static state.
*/
Expand Down
5 changes: 3 additions & 2 deletions src/mail/src/Mailable.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
use Symfony\Component\Mailer\Header\MetadataHeader;
use Symfony\Component\Mailer\Header\TagHeader;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;

class Mailable implements MailableContract, Renderable
{
Expand Down Expand Up @@ -559,7 +560,7 @@ public function locale(?string $locale): static
*/
public function priority(int $level = 3): static
{
$this->callbacks[] = function ($message) use ($level) {
$this->callbacks[] = static function (Email $message) use ($level): void {
$message->priority($level);
};

Expand Down Expand Up @@ -1496,7 +1497,7 @@ private function ensureHeadersAreHydrated(): void

$headers = $this->headers();

$this->withSymfonyMessage(function ($message) use ($headers) {
$this->withSymfonyMessage(static function (Email $message) use ($headers): void {
if ($headers->messageId) {
$message->getHeaders()->addIdHeader('Message-Id', $headers->messageId);
}
Expand Down
Loading
Loading