NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

A certificate is expiring once it is issued, renewed or failed and its live certificate expires within renewal.threshold_days (default 21). Certificates::renewDue() renews every such certificate — or queues each one — and returns a RenewalReport:

use RoundlyConsulting\Certificates\Facades\Certificates;

$report = Certificates::renewDue();          // everything inside renewal.threshold_days → RenewalReport
Certificates::renewDue(7, queue: true);      // a 7-day window, one queued job per certificate

if ($report->hasFailures()) {
    foreach ($report->failed as $failure) {
        logger()->warning("Renewal failed for {$failure->certificate->domain}: {$failure->reason()}");
    }
}

$report->renewedDomains();   // ['a.example.com', …]; also queuedDomains(), failedDomains(), count(), isEmpty()

Each due certificate is attempted on its own: one failure never stops the rest. The report lists renewed, queued (with queue: true) and failed — each failure a RenewalFailure carrying the certificate and the exception, with reason() for its message. Every failure is also passed to your exception handler. A failed inline renewal leaves the row failed with CertificateFailed fired; a failed dispatch leaves the row untouched, so the next run retries it.

One certificate

renew() and renewLater() take a registry Certificate or a domain (its most recent row), flat or through the for() handle. An unknown domain, or a status that cannot renew (a revoked certificate, say), throws CertificateException — renewLater() checks the status before it queues anything:

use RoundlyConsulting\Certificates\Facades\Certificates;

Certificates::renew('app.example.com');        // now, through the certificate's own driver
Certificates::renewLater('app.example.com');   // queue RenewCertificateJob (renewal.queue)
Certificates::renew($certificate);             // a registry row works as well as a domain

Certificates::for('app.example.com')->renew();                // same verbs on the domain handle
Certificates::for('app.example.com')->using('acme')->renewLater(); // only the acme row is touched

Certificates::expiring(14);   // Collection<int, Certificate> expiring within 14 days, soonest first

From the console

certificates:renew runs Certificates::renewDue() — or, given a domain, Certificates::renew() for its most recent row (renewLater() with --queue). It prints every certificate’s outcome and exits non-zero when any renewal or dispatch failed, so the scheduler’s failure hooks fire:

# everything expiring within renewal.threshold_days
php artisan certificates:renew

# one domain, whatever its expiry
php artisan certificates:renew shop.example.com

# a wider window, renewed through the queue
php artisan certificates:renew --threshold=30 --queue

Scheduling

The package registers no schedule — keep that in your app, for example in routes/console.php:

use Illuminate\Support\Facades\Schedule;
use RoundlyConsulting\Certificates\Facades\Certificates;

Schedule::command('certificates:renew')->daily()->emailOutputOnFailure('[email protected]');

// or, without the command:
Schedule::call(fn () => Certificates::renewDue(queue: true))->daily();

emailOutputOnFailure() mails the command’s output whenever certificates:renew exits non-zero — when any inline renewal or any dispatch failed — so it needs a working mailer. With --queue, a renewal that fails later inside RenewCertificateJob doesn’t change the exit code: the row moves to failed and CertificateFailed fires, which raises an alert when alerts.enabled is on.

The renewal lifecycle

  • renewDue() dispatches CertificateExpiring for each certificate it picks up.
  • Issued, renewed and failed certificates can renew — anything else throws CertificateException for an illegal status transition.
  • The row moves to renewing and the certificate’s own driver re-provisions every domain it covers — a SAN certificate keeps all its hosts.
  • The renewal is proven by the provider’s own report: a status that is not live, or the very same certificate (same fingerprint, e.g. imported material nobody replaced), is a failed renewal.
  • Otherwise the row becomes renewed with the reported expiry (90 days out when the driver reports none), issuer, serial and fingerprint, last_renewed_at is stamped and CertificateRenewed fires. The certificate’s cached status report is dropped.
  • A failed renewal moves the row to failed, fires CertificateFailed and rethrows the exception — it never sits in renewing, so it raises an alert instead. Failed is not a dead end: expiring(), renewDue() and certificates:renew keep retrying it while its live certificate runs out.

Per driver

acme places a fresh order with a new certificate key and overwrites the stored material. kubernetes finds the Ingress TLS entry already in place and leaves it untouched — cert-manager rotates the secret itself — then records the notAfter it reports. filesystem with self_signed mints fresh material; without it, replace the PEM files before renewing, or the renewal fails as not renewed.

Queued renewals

renewLater(), renewDue(queue: true) and certificates:renew --queue dispatch a RenewCertificateJob per certificate instead of renewing inline. The job lands on the queue named by renewal.queue (CERTIFICATES_RENEW_QUEUE), re-reads the row on the database connection it was queued from and skips certificates deleted in the meantime.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.