• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

daycry / jobs / 25509260188

07 May 2026 03:36PM UTC coverage: 75.053%. First build
25509260188

push

github

daycry
feat(v2.0): opt-in API with immutable JobDefinition + lease-based QueueBackend

Introduces the new Daycry\Jobs\V2\ namespace alongside the existing v1 API
so adopters can migrate incrementally. Nothing in v1 changes: legacy Job
builders, QueueInterface and WorkerInterface keep working unchanged.

- JobDefinition: readonly value object replacing the mutable Job builder;
  withXxx() copies for safe sharing across enqueue sites; fromLegacyJob()
  bridges from the v1 mutable builder.
- JobLease + QueueBackend: unified enqueue/fetch/ack/nack/abandon contract
  that replaces the QueueInterface+WorkerInterface split, removing the
  per-instance hidden state in legacy backends.
- LegacyWorkerAdapter wraps any v1 backend so worker code can already use
  the new lease API without touching the underlying implementation.
- TypedJobHandler base class rehydrates payloads to a declared DTO via
  reflection so handlers receive typed objects instead of mixed.
- docs/V2_MIGRATION.md documents the four-step adoption path and the
  proposed deprecation timeline (v1.2 -> v1.3 native -> v2.0 -> v3.0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

118 of 144 new or added lines in 4 files covered. (81.94%)

2139 of 2850 relevant lines covered (75.05%)

12.03 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

67.5
/src/V2/Handlers/TypedJobHandler.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of Daycry Queues.
7
 *
8
 * (c) Daycry <daycry9@proton.me>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace Daycry\Jobs\V2\Handlers;
15

16
use Daycry\Jobs\Exceptions\JobException;
17
use Daycry\Jobs\Interfaces\JobInterface;
18
use Daycry\Jobs\Job;
19
use ReflectionClass;
20
use ReflectionException;
21
use Throwable;
22

23
/**
24
 * Base class for handlers that want to receive a *typed* payload object instead of
25
 * the loosely-typed `mixed` value the v1 JobInterface accepts.
26
 *
27
 * Subclasses declare the payload class via {@see payloadType()} and implement
28
 * {@see run()} which is called with an instance of that class. If the queue carried
29
 * a serialised representation (associative array or stdClass with public properties),
30
 * TypedJobHandler will rehydrate it via the public constructor or by setting matching
31
 * public properties — whichever the target class supports.
32
 *
33
 * Example:
34
 * ```
35
 * final class ProcessImport extends TypedJobHandler
36
 * {
37
 *     public function payloadType(): string { return ImportRequest::class; }
38
 *     protected function run(object $payload): mixed { ... }
39
 * }
40
 * ```
41
 */
42
abstract class TypedJobHandler extends Job implements JobInterface
43
{
44
    /**
45
     * @return class-string FQCN of the DTO that {@see run()} expects to receive.
46
     */
47
    abstract public function payloadType(): string;
48

49
    /**
50
     * Process the rehydrated payload. Return value semantics are the same as v1:
51
     * the value is recorded as the job's output.
52
     */
53
    abstract protected function run(object $payload): mixed;
54

55
    public function handle(mixed $payload): mixed
56
    {
57
        $expected = $this->payloadType();
5✔
58
        if (! class_exists($expected)) {
5✔
59
            throw JobException::validationError("TypedJobHandler::payloadType() must return an existing class, got '{$expected}'.");
1✔
60
        }
61

62
        $instance = $payload instanceof $expected ? $payload : $this->hydrate($payload, $expected);
4✔
63

64
        return $this->run($instance);
3✔
65
    }
66

67
    public function beforeRun(Job $job): Job
68
    {
NEW
69
        return $job;
×
70
    }
71

72
    public function afterRun(Job $job): Job
73
    {
NEW
74
        return $job;
×
75
    }
76

77
    /**
78
     * @param class-string $type
79
     */
80
    private function hydrate(mixed $payload, string $type): object
81
    {
82
        $data = $this->normalisePayload($payload);
3✔
83

84
        // Constructor-based hydration: if the target class accepts an associative array
85
        // (e.g. via array unpacking or named arguments), prefer that; otherwise fall back
86
        // to setting public properties one by one.
87
        try {
88
            $reflection = new ReflectionClass($type);
2✔
89
            $ctor       = $reflection->getConstructor();
2✔
90

91
            if ($ctor === null) {
2✔
92
                /** @var object $instance */
NEW
93
                $instance = $reflection->newInstance();
×
94
            } else {
95
                $args = [];
2✔
96

97
                foreach ($ctor->getParameters() as $param) {
2✔
98
                    $name = $param->getName();
2✔
99
                    if (array_key_exists($name, $data)) {
2✔
100
                        $args[$name] = $data[$name];
2✔
NEW
101
                    } elseif ($param->isDefaultValueAvailable()) {
×
NEW
102
                        $args[$name] = $param->getDefaultValue();
×
NEW
103
                    } elseif ($param->allowsNull()) {
×
NEW
104
                        $args[$name] = null;
×
105
                    } else {
NEW
106
                        throw JobException::validationError("TypedJobHandler cannot hydrate '{$type}': missing required parameter '{$name}'.");
×
107
                    }
108
                }
109

110
                /** @var object $instance */
111
                $instance = $reflection->newInstanceArgs($args);
2✔
112
            }
NEW
113
        } catch (ReflectionException $e) {
×
NEW
114
            throw JobException::validationError("TypedJobHandler hydration failed for '{$type}': " . $e->getMessage());
×
115
        }
116

117
        // Best-effort: if there are leftover keys not consumed by the constructor and the
118
        // class has matching public properties, set them so optional fields survive the
119
        // round-trip without forcing every DTO to take every value through the constructor.
120
        foreach ($data as $key => $value) {
2✔
121
            if (! property_exists($instance, $key)) {
2✔
NEW
122
                continue;
×
123
            }
124

125
            try {
126
                /** @phpstan-ignore property.dynamicName (intentional dynamic hydration) */
127
                $instance->{$key} = $value;
2✔
NEW
128
            } catch (Throwable) {
×
129
                // Ignore protected/typed mismatches — constructor-only fields stay untouched.
130
            }
131
        }
132

133
        return $instance;
2✔
134
    }
135

136
    /**
137
     * Convert raw payload into an associative array.
138
     *
139
     * @return array<string, mixed>
140
     */
141
    private function normalisePayload(mixed $payload): array
142
    {
143
        if (is_array($payload)) {
3✔
144
            return $payload;
1✔
145
        }
146
        if (is_object($payload)) {
2✔
NEW
147
            return get_object_vars($payload);
×
148
        }
149
        if (is_string($payload)) {
2✔
150
            $decoded = json_decode($payload, true);
1✔
151
            if (is_array($decoded)) {
1✔
152
                return $decoded;
1✔
153
            }
154
        }
155

156
        throw JobException::validationError('TypedJobHandler payload must be an array, object, or JSON string.');
1✔
157
    }
158
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc