Skip to content

Commit e9f2e6f

Browse files
authored
Merge pull request #4 from blendbyte/event-hooks-callbacks
Add onIssued/onRenewed event callbacks
2 parents f6c35a2 + 19e1287 commit e9f2e6f

3 files changed

Lines changed: 205 additions & 4 deletions

File tree

README.md

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ echo $cert->caBundle; // PEM intermediate chain
120120
- [Challenge handlers](#challenge-handlers)
121121
- [Storage backends](#storage-backends)
122122
- [Issuing certificates](#issuing-certificates)
123+
- [Event callbacks](#event-callbacks)
123124
- [CAA pre-check](#caa-pre-check)
124125
- [Wildcard and multi-domain certificates](#wildcard-and-multi-domain-certificates)
125126
- [IP address certificates](#ip-address-certificates-rfc-8738)
@@ -537,6 +538,47 @@ Returns `true` when:
537538

538539
---
539540

541+
## Event callbacks
542+
543+
Register callbacks on the builder to react to certificate lifecycle events without subclassing or parsing log output. Useful for reloading a web server, pushing secrets to a vault, or sending a Slack notification.
544+
545+
### onIssued
546+
547+
Fires after every successful certificate issuance — whether first-time or a renewal.
548+
549+
```php
550+
CoyoteCert::with(new LetsEncrypt())
551+
->storage(new FilesystemStorage('/var/certs'))
552+
->identifiers('example.com')
553+
->challenge(new Http01Handler('/var/www/html'))
554+
->onIssued(function (StoredCertificate $cert): void {
555+
SecretsManager::push('tls/example.com', [
556+
'cert' => $cert->certificate,
557+
'key' => $cert->privateKey,
558+
'fullchain'=> $cert->fullchain,
559+
]);
560+
})
561+
->issueOrRenew();
562+
```
563+
564+
### onRenewed
565+
566+
Fires only when an existing certificate is replaced — i.e. storage already held a cert before the new one was issued. Fires _after_ `onIssued` callbacks.
567+
568+
```php
569+
CoyoteCert::with(new LetsEncrypt())
570+
->storage(new FilesystemStorage('/var/certs'))
571+
->identifiers('example.com')
572+
->challenge(new Http01Handler('/var/www/html'))
573+
->onIssued(fn($cert) => SecretsManager::push('tls/example.com', $cert->toArray()))
574+
->onRenewed(fn($cert) => Nginx::reload())
575+
->issueOrRenew();
576+
```
577+
578+
Both methods accept any `callable` and can be called multiple times. Callbacks run in registration order, after the certificate has been saved to storage.
579+
580+
---
581+
540582
## CAA pre-check
541583

542584
[CAA (Certification Authority Authorization)](https://en.wikipedia.org/wiki/DNS_Certification_Authority_Authorization) is a DNS record type that restricts which CAs are allowed to issue certificates for a domain. If `example.com` has `CAA 0 issue "digicert.com"`, Let's Encrypt will refuse the order — but only after you have consumed a rate-limit attempt and waited for the ACME workflow to fail.
@@ -660,10 +702,8 @@ $cert = CoyoteCert::with(new LetsEncrypt())
660702
->identifiers(['example.com', 'www.example.com'])
661703
->challenge(new Http01Handler('/var/www/html'))
662704
->logger($logger)
705+
->onRenewed(fn($cert) => exec('systemctl reload nginx'))
663706
->issueOrRenew(daysBeforeExpiry: 30);
664-
665-
// Reload web server only if a new certificate was issued
666-
// (compare serial or expiry to detect renewal)
667707
```
668708

669709
Add to crontab. Daily is sufficient; `issueOrRenew()` skips the CA call when nothing is due:
@@ -893,6 +933,8 @@ CoyoteCert::with(AcmeProviderInterface $provider) // factory — select the CA
893933
| `->logger(LoggerInterface)` | fluent | none | PSR-3 logger |
894934
| `->skipLocalTest()` | fluent | off | Disable pre-flight HTTP/DNS self-check |
895935
| `->skipCaaCheck()` | fluent | off | Disable CAA DNS pre-check (internal CAs, split-horizon DNS) |
936+
| `->onIssued(callable)` | fluent | none | Callback fired after every successful issuance; receives `StoredCertificate` |
937+
| `->onRenewed(callable)` | fluent | none | Callback fired when an existing cert is replaced; receives `StoredCertificate` |
896938
| `->issue()` | terminal || Issue unconditionally; returns `StoredCertificate` |
897939
| `->renew()` | terminal || Alias for `issue()` |
898940
| `->issueOrRenew(int $days = 30)` | terminal || Issue only when needed; returns `StoredCertificate` |

src/CoyoteCert.php

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ class CoyoteCert
5757
private KeyType $accountKeyType = KeyType::EC_P256;
5858
private bool $localTest = true;
5959
private bool $skipCaaCheck = false;
60+
/** @var callable[] */
61+
private array $onIssuedCallbacks = [];
62+
/** @var callable[] */
63+
private array $onRenewedCallbacks = [];
6064

6165
private function __construct(private readonly AcmeProviderInterface $provider) {}
6266

@@ -180,6 +184,31 @@ public function skipCaaCheck(): self
180184
return $this;
181185
}
182186

187+
/**
188+
* Register a callback invoked after every successful certificate issuance.
189+
* The callback receives the issued StoredCertificate as its sole argument.
190+
* Multiple callbacks may be registered; they run in registration order.
191+
*/
192+
public function onIssued(callable $callback): self
193+
{
194+
$this->onIssuedCallbacks[] = $callback;
195+
196+
return $this;
197+
}
198+
199+
/**
200+
* Register a callback invoked after a certificate is renewed (i.e. an existing
201+
* certificate was replaced). Fires in addition to onIssued callbacks.
202+
* The callback receives the new StoredCertificate as its sole argument.
203+
* Multiple callbacks may be registered; they run in registration order.
204+
*/
205+
public function onRenewed(callable $callback): self
206+
{
207+
$this->onRenewedCallbacks[] = $callback;
208+
209+
return $this;
210+
}
211+
183212
/**
184213
* Set the HTTP timeout in seconds for the built-in curl client.
185214
* No-op when a custom PSR-18 client is configured.
@@ -277,7 +306,10 @@ public function issue(): StoredCertificate
277306
// Refresh order — status transitions pending → ready after all challenges pass
278307
$order = $api->order()->refresh($order);
279308

280-
return $this->fetchAndStoreCertificate($api, $order);
309+
$stored = $this->fetchAndStoreCertificate($api, $order);
310+
$this->fireIssuedCallbacks($stored, isRenewal: $existingCert !== null);
311+
312+
return $stored;
281313
}
282314

283315
// ── Private issue() helpers ───────────────────────────────────────────────
@@ -359,6 +391,19 @@ private function fetchAndStoreCertificate(
359391
return $stored;
360392
}
361393

394+
private function fireIssuedCallbacks(StoredCertificate $cert, bool $isRenewal): void
395+
{
396+
foreach ($this->onIssuedCallbacks as $cb) {
397+
$cb($cert);
398+
}
399+
400+
if ($isRenewal) {
401+
foreach ($this->onRenewedCallbacks as $cb) {
402+
$cb($cert);
403+
}
404+
}
405+
}
406+
362407
/**
363408
* Alias for issue() — forces a fresh certificate regardless of expiry.
364409
*/

tests/Unit/CoyoteCertTest.php

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,120 @@ public function sendRequest(\Psr\Http\Message\RequestInterface $r): \Psr\Http\Me
393393
expect($timeoutRef->getValue($client))->toBe(30);
394394
});
395395

396+
// ── onIssued() / onRenewed() ──────────────────────────────────────────────────
397+
398+
it('onIssued() returns self (fluent)', function () {
399+
$c = makeCoyote();
400+
expect($c->onIssued(fn($cert) => null))->toBe($c);
401+
});
402+
403+
it('onRenewed() returns self (fluent)', function () {
404+
$c = makeCoyote();
405+
expect($c->onRenewed(fn($cert) => null))->toBe($c);
406+
});
407+
408+
it('onIssued() registers multiple callbacks', function () {
409+
$c = makeCoyote();
410+
$c->onIssued(fn($cert) => null);
411+
$c->onIssued(fn($cert) => null);
412+
413+
$ref = new \ReflectionProperty(CoyoteCert::class, 'onIssuedCallbacks');
414+
expect($ref->getValue($c))->toHaveCount(2);
415+
});
416+
417+
it('onRenewed() registers multiple callbacks', function () {
418+
$c = makeCoyote();
419+
$c->onRenewed(fn($cert) => null);
420+
$c->onRenewed(fn($cert) => null);
421+
422+
$ref = new \ReflectionProperty(CoyoteCert::class, 'onRenewedCallbacks');
423+
expect($ref->getValue($c))->toHaveCount(2);
424+
});
425+
426+
it('fireIssuedCallbacks() invokes onIssued callbacks with the certificate', function () {
427+
$cert = makeCoyoteCert();
428+
$coyote = makeCoyote();
429+
$received = null;
430+
431+
$coyote->onIssued(function ($c) use (&$received) {
432+
$received = $c;
433+
});
434+
435+
$method = new \ReflectionMethod(CoyoteCert::class, 'fireIssuedCallbacks');
436+
$method->invoke($coyote, $cert, false);
437+
438+
expect($received)->toBe($cert);
439+
});
440+
441+
it('fireIssuedCallbacks() does not invoke onRenewed callbacks when isRenewal is false', function () {
442+
$cert = makeCoyoteCert();
443+
$coyote = makeCoyote();
444+
$called = false;
445+
446+
$coyote->onRenewed(function () use (&$called) {
447+
$called = true;
448+
});
449+
450+
$method = new \ReflectionMethod(CoyoteCert::class, 'fireIssuedCallbacks');
451+
$method->invoke($coyote, $cert, false);
452+
453+
expect($called)->toBeFalse();
454+
});
455+
456+
it('fireIssuedCallbacks() invokes onRenewed callbacks when isRenewal is true', function () {
457+
$cert = makeCoyoteCert();
458+
$coyote = makeCoyote();
459+
$received = null;
460+
461+
$coyote->onRenewed(function ($c) use (&$received) {
462+
$received = $c;
463+
});
464+
465+
$method = new \ReflectionMethod(CoyoteCert::class, 'fireIssuedCallbacks');
466+
$method->invoke($coyote, $cert, true);
467+
468+
expect($received)->toBe($cert);
469+
});
470+
471+
it('fireIssuedCallbacks() invokes both onIssued and onRenewed when isRenewal is true', function () {
472+
$cert = makeCoyoteCert();
473+
$coyote = makeCoyote();
474+
$log = [];
475+
476+
$coyote->onIssued(function () use (&$log) {
477+
$log[] = 'issued';
478+
});
479+
$coyote->onRenewed(function () use (&$log) {
480+
$log[] = 'renewed';
481+
});
482+
483+
$method = new \ReflectionMethod(CoyoteCert::class, 'fireIssuedCallbacks');
484+
$method->invoke($coyote, $cert, true);
485+
486+
expect($log)->toBe(['issued', 'renewed']);
487+
});
488+
489+
it('fireIssuedCallbacks() invokes callbacks in registration order', function () {
490+
$cert = makeCoyoteCert();
491+
$coyote = makeCoyote();
492+
$log = [];
493+
494+
$coyote->onIssued(function () use (&$log) {
495+
$log[] = 1;
496+
});
497+
$coyote->onIssued(function () use (&$log) {
498+
$log[] = 2;
499+
});
500+
$coyote->onIssued(function () use (&$log) {
501+
$log[] = 3;
502+
});
503+
504+
$method = new \ReflectionMethod(CoyoteCert::class, 'fireIssuedCallbacks');
505+
$method->invoke($coyote, $cert, false);
506+
507+
expect($log)->toBe([1, 2, 3]);
508+
});
509+
396510
// ── extractTokenAndKeyAuth() ──────────────────────────────────────────────────
397511

398512
it('extractTokenAndKeyAuth() returns [name, value] for Dns01ValidationData', function () {

0 commit comments

Comments
 (0)