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

daycry / jobs / 26886467550

03 Jun 2026 01:01PM UTC coverage: 88.948% (+14.0%) from 74.974%
26886467550

push

github

web-flow
v3.0: single clean architecture (remove V1, lease-based queues, secure-by-default)

Complete v3.0 rewrite into a single, clean architecture. The v1 API and the V2\ scaffolding
are removed (no facade, no dual code); the package passes PHPStan level 6 + strict-rules +
codeigniter with NO baseline.

- Definition: Jobs::define()->...->dispatch() fluent builder -> immutable JobDefinition.
- Handlers decoupled from the god-object (JobHandlerInterface / AbstractJobHandler / TypedJobHandler + JobContext).
- One QueueBackend contract (enqueue/fetch(lease)/ack/nack(delay)/abandon/reapExpired) with 5 backends:
  Sync, Database, Redis, Beanstalk, ServiceBus.
- Runtime: one attempt per fetch; real interrupting Timeout; opt-in idempotency; single-instance lock.
- Worker/Cron: jobs:queue:work, jobs:queue:reap, jobs:cronjob:run, jobs:queue:purge.
- Secure-by-default: HMAC-signed envelopes, per-queue handler allowlist, ShellHandler deny-by-default,
  EventHandler allowlist, UrlHandler anti-SSRF.

Resolves audit findings #1,#2,#3,#4,#5,#6,#7,#8,#10,#12,#13,#17,#18,#19,#20,#22.
Tests: 359 (Beanstalk live); line coverage 88.9%; PHPStan/Psalm/Rector/cs green on PHP 8.2-8.5.

BREAKING CHANGE: v1 API removed. See docs/MIGRATION-v1-to-v3.md.

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

983 of 1103 new or added lines in 43 files covered. (89.12%)

15 existing lines in 3 files now uncovered.

1497 of 1683 relevant lines covered (88.95%)

7.55 hits per line

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

97.78
/src/Definition/JobBuilder.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\Definition;
15

16
use Cron\CronExpression;
17
use DateTimeImmutable;
18
use Daycry\Jobs\Config\Jobs;
19
use Daycry\Jobs\Queues\BackendFactory;
20
use RuntimeException;
21

22
/**
23
 * Mutable, fluent builder that accumulates job configuration and produces an immutable
24
 * {@see JobDefinition}.
25
 *
26
 * This is the v3 replacement for the v1 mutable {@see \Daycry\Jobs\Job} API. Where Job mixed
27
 * scheduling, identity and dispatch into a single long-lived mutable object, JobBuilder is a
28
 * throwaway accumulator: callers chain setters and then materialise the result with
29
 * {@see toDefinition()} (a value object) or {@see dispatch()} (enqueue onto a backend).
30
 *
31
 * Frequency helpers port the exact cron semantics of {@see \Daycry\Jobs\Traits\FrequenciesTrait}
32
 * by keeping the five standard cron fields (minute, hour, day-of-month, month, day-of-week) and
33
 * recomposing the expression on every mutation.
34
 */
35
final class JobBuilder
36
{
37
    private ?string $name                   = null;
38
    private ?string $queue                  = null;
39
    private int $priority                   = 5;
40
    private ?int $maxRetries                = 0;
41
    private ?int $timeout                   = null;
42
    private ?DateTimeImmutable $scheduledAt = null;
43
    private bool $singleInstance            = false;
44

45
    /**
46
     * @var list<string>
47
     */
48
    private array $environments = [];
49

50
    /**
51
     * @var list<string>
52
     */
53
    private array $dependsOn = [];
54

55
    private ?string $idempotencyKey = null;
56
    private bool $enabled           = true;
57

58
    /**
59
     * The five standard cron fields: minute, hour, day-of-month, month, day-of-week.
60
     *
61
     * @var array{0: string, 1: string, 2: string, 3: string, 4: string}
62
     */
63
    private array $cronParts = ['*', '*', '*', '*', '*'];
64

65
    private readonly Jobs $config;
66

67
    public function __construct(
68
        private readonly string $handler,
69
        private readonly mixed $payload = null,
70
        ?Jobs $config = null,
71
    ) {
72
        $this->config = $config ?? config('Jobs');
34✔
73
    }
74

75
    /**
76
     * The resolved library configuration backing this builder.
77
     *
78
     * The v3 facade {@see \Daycry\Jobs\Jobs} routes its config resolution through a builder
79
     * instance: calling config('Jobs') from the root {@see \Daycry\Jobs} namespace is
80
     * ambiguous (the facade class is also named Jobs), whereas resolving it via the readonly
81
     * {@see Jobs} property here yields the {@see \Daycry\Jobs\Config\Jobs} type cleanly.
82
     */
83
    public function config(): Jobs
84
    {
85
        return $this->config;
1✔
86
    }
87

88
    public function named(string $name): self
89
    {
90
        $this->name = $name;
17✔
91

92
        return $this;
17✔
93
    }
94

95
    public function queue(?string $queue): self
96
    {
97
        $this->queue = $queue;
6✔
98

99
        return $this;
6✔
100
    }
101

102
    public function priority(int $priority): self
103
    {
104
        $this->priority = $priority;
2✔
105

106
        return $this;
2✔
107
    }
108

109
    public function maxRetries(?int $maxRetries): self
110
    {
111
        $this->maxRetries = $maxRetries;
1✔
112

113
        return $this;
1✔
114
    }
115

116
    public function timeout(?int $timeout): self
117
    {
118
        $this->timeout = $timeout;
1✔
119

120
        return $this;
1✔
121
    }
122

123
    public function scheduledAt(?DateTimeImmutable $when): self
124
    {
125
        $this->scheduledAt = $when;
1✔
126

127
        return $this;
1✔
128
    }
129

130
    public function singleInstance(bool $singleInstance = true): self
131
    {
132
        $this->singleInstance = $singleInstance;
1✔
133

134
        return $this;
1✔
135
    }
136

137
    /**
138
     * Restrict execution to the given CI4 environments. Accepts either a variadic list or a
139
     * single array argument.
140
     *
141
     * @param list<string>|string $envs
142
     */
143
    public function environments(array|string ...$envs): self
144
    {
145
        $this->environments = $this->flatten($envs);
3✔
146

147
        return $this;
3✔
148
    }
149

150
    /**
151
     * Declare the job names that must succeed first. Accepts either a variadic list or a single
152
     * array argument.
153
     *
154
     * @param list<string>|string $names
155
     */
156
    public function dependsOn(array|string ...$names): self
157
    {
158
        $this->dependsOn = $this->flatten($names);
6✔
159

160
        return $this;
6✔
161
    }
162

163
    public function idempotencyKey(?string $idempotencyKey): self
164
    {
165
        $this->idempotencyKey = $idempotencyKey;
1✔
166

167
        return $this;
1✔
168
    }
169

170
    public function enabled(bool $enabled = true): self
171
    {
172
        $this->enabled = $enabled;
5✔
173

174
        return $this;
5✔
175
    }
176

177
    public function disable(): self
178
    {
179
        return $this->enabled(false);
4✔
180
    }
181

182
    /**
183
     * Set the schedule from a raw crontab expression string.
184
     *
185
     * @throws RuntimeException When the expression is not a valid cron string.
186
     */
187
    public function cron(string $expression): self
188
    {
189
        if (! CronExpression::isValidExpression($expression)) {
2✔
190
            throw new RuntimeException('Invalid cron expression: ' . $expression);
1✔
191
        }
192

193
        $this->setExpression((new CronExpression($expression))->getExpression());
1✔
194

195
        return $this;
1✔
196
    }
197

198
    // ============================================================
199
    // Frequency helpers (ported from FrequenciesTrait semantics)
200
    // ============================================================
201

202
    /**
203
     * Run every minute, or every $minutes minutes when provided.
204
     */
205
    public function everyMinute(?int $minutes = null): self
206
    {
207
        return $this->applyParts([0 => $minutes === null ? '*' : '*/' . $minutes]);
11✔
208
    }
209

210
    /**
211
     * Run every $minutes minutes (alias of {@see everyMinute()} with an explicit interval).
212
     */
213
    public function everyXMinutes(int $minutes): self
214
    {
215
        return $this->applyParts([0 => '*/' . $minutes]);
1✔
216
    }
217

218
    /**
219
     * Run at the top of every hour.
220
     */
221
    public function hourly(): self
222
    {
223
        return $this->applyParts([0 => '0', 1 => '*']);
1✔
224
    }
225

226
    /**
227
     * Run once an hour at the given minute past the hour.
228
     */
229
    public function hourlyAt(int $minute): self
230
    {
231
        return $this->applyParts([0 => (string) $minute, 1 => '*']);
1✔
232
    }
233

234
    /**
235
     * Run daily at midnight.
236
     */
237
    public function daily(): self
238
    {
239
        return $this->applyParts([0 => '0', 1 => '0']);
2✔
240
    }
241

242
    /**
243
     * Run daily at the given 'HH:MM' time.
244
     */
245
    public function dailyAt(string $time): self
246
    {
247
        [$minute, $hour] = $this->parseTime($time);
5✔
248

249
        return $this->applyParts([0 => $minute, 1 => $hour]);
5✔
250
    }
251

252
    /**
253
     * Run weekly on Sunday at midnight.
254
     */
255
    public function weekly(): self
256
    {
257
        return $this->applyParts([0 => '0', 1 => '0', 4 => '0']);
1✔
258
    }
259

260
    /**
261
     * Run on the first day of every month at midnight.
262
     */
263
    public function monthly(): self
264
    {
265
        return $this->applyParts([0 => '0', 1 => '0', 2 => '1']);
1✔
266
    }
267

268
    /**
269
     * Run on the first day of each quarter (Jan/Apr/Jul/Oct) at midnight.
270
     */
271
    public function quarterly(): self
272
    {
273
        return $this->applyParts([0 => '0', 1 => '0', 2 => '1', 3 => '*/3']);
1✔
274
    }
275

276
    /**
277
     * Run on the first day of the year at midnight.
278
     */
279
    public function yearly(): self
280
    {
281
        return $this->applyParts([0 => '0', 1 => '0', 2 => '1', 3 => '1']);
1✔
282
    }
283

284
    // ============================================================
285
    // Terminators
286
    // ============================================================
287

288
    /**
289
     * Materialise the accumulated configuration into an immutable {@see JobDefinition}.
290
     */
291
    public function toDefinition(): JobDefinition
292
    {
293
        return new JobDefinition(
30✔
294
            handler: $this->handler,
30✔
295
            payload: $this->payload,
30✔
296
            name: $this->name,
30✔
297
            queue: $this->queue,
30✔
298
            priority: $this->priority,
30✔
299
            maxRetries: $this->maxRetries,
30✔
300
            timeout: $this->timeout,
30✔
301
            scheduledAt: $this->scheduledAt,
30✔
302
            singleInstance: $this->singleInstance,
30✔
303
            environments: $this->environments,
30✔
304
            dependsOn: $this->dependsOn,
30✔
305
            cronExpression: $this->composeExpression(),
30✔
306
            meta: [],
30✔
307
            enabled: $this->enabled,
30✔
308
            idempotencyKey: $this->idempotencyKey,
30✔
309
        );
30✔
310
    }
311

312
    /**
313
     * Enqueue the definition onto the named backend (or the configured default) and return the
314
     * backend-assigned id.
315
     */
316
    public function dispatch(?string $backend = null): string
317
    {
318
        $resolved = BackendFactory::make($this->config, $backend);
2✔
319

320
        return $resolved->enqueue($this->toDefinition());
2✔
321
    }
322

323
    // ============================================================
324
    // Internal cron helpers
325
    // ============================================================
326

327
    /**
328
     * Apply field overrides keyed by cron field index (0-4) onto the running parts, then
329
     * recompose through {@see CronExpression} so the stored expression is always normalised.
330
     *
331
     * @param array<int, string> $overrides
332
     */
333
    private function applyParts(array $overrides): self
334
    {
335
        $cron = new CronExpression($this->composeExpression());
18✔
336

337
        foreach ($overrides as $index => $value) {
18✔
338
            $cron->setPart($index, $value);
18✔
339
        }
340

341
        $this->setExpression($cron->getExpression());
18✔
342

343
        return $this;
18✔
344
    }
345

346
    private function composeExpression(): string
347
    {
348
        return implode(' ', $this->cronParts);
31✔
349
    }
350

351
    private function setExpression(string $expression): void
352
    {
353
        $fields = explode(' ', $expression);
19✔
354

355
        if (count($fields) !== 5) {
19✔
NEW
356
            throw new RuntimeException('Invalid cron expression: ' . $expression);
×
357
        }
358

359
        $this->cronParts = [
19✔
360
            0 => $fields[0],
19✔
361
            1 => $fields[1],
19✔
362
            2 => $fields[2],
19✔
363
            3 => $fields[3],
19✔
364
            4 => $fields[4],
19✔
365
        ];
19✔
366
    }
367

368
    /**
369
     * Parse a 'HH:MM' time string into [minute, hour] cron field strings, normalised without
370
     * leading zeros (so '02:30' yields '2' for the hour, matching standard crontab output).
371
     *
372
     * @return array{0: string, 1: string} [minute, hour]
373
     */
374
    private function parseTime(string $time): array
375
    {
376
        $timestamp = strtotime($time);
5✔
377

378
        if ($timestamp === false) {
5✔
NEW
379
            throw new RuntimeException('Invalid time string: ' . $time);
×
380
        }
381

382
        return [
5✔
383
            (string) (int) date('i', $timestamp),
5✔
384
            (string) (int) date('H', $timestamp),
5✔
385
        ];
5✔
386
    }
387

388
    /**
389
     * Flatten a variadic of strings and/or string arrays into a single re-indexed list.
390
     *
391
     * @param array<int, list<string>|string> $values
392
     *
393
     * @return list<string>
394
     */
395
    private function flatten(array $values): array
396
    {
397
        $flat = [];
9✔
398

399
        foreach ($values as $value) {
9✔
400
            if (is_array($value)) {
9✔
401
                foreach ($value as $item) {
2✔
402
                    $flat[] = $item;
2✔
403
                }
404

405
                continue;
2✔
406
            }
407

408
            $flat[] = $value;
9✔
409
        }
410

411
        return $flat;
9✔
412
    }
413
}
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