lara-zeus/qr
Lara Zeus QR is a Laravel package for generating customizable QR codes for URLs, text, and models, with easy Blade and Filament integration, styling options, and downloadable output.
Confirm the environment before writing any code. The package renders images, so the image extension decides whether anything works:
php -m | grep -Ei '^(gd|imagick)$' # must return at least one
php -r 'var_dump(gd_info()["PNG Support"] ?? false);'
Check this in every runtime that will render a code: local, the web container, the queue worker image, and your CI image. Missing GD/Imagick is the single most common install-time surprise and it fails at render time, not at install time.
Install pinned, never *:
composer require lara-zeus/qr:^3.0.1
Do not allow 3.0.0 anywhere. Release 3.0.1's one substantive change is "fix encoding the image" (#74) — that is a silent output-corruption class of bug (bad bytes, wrong content-type, broken data: URIs), so treat 3.0.0 as an active defect, not a nicety. If you already run 3.0.0, bump and diff rendered bytes against a stored fixture before you ship.
Where to look first, in this order:
composer show lara-zeus/qr and the package src/ — confirm the exact class names for the Blade component and the Filament field/column. Grep is faster and more reliable than guessing from README prose.config/ — whether a config file ships and what the defaults are (size, margin, colours, error correction).resources/views/ — the component markup, if you plan to restyle or wrap it.php artisan vendor:publish --tag=zeus-qr-config # confirm the tag from the README / provider
First use case: one Filament resource, one field. Don't start with a public-facing page. Add the QR field to an existing admin resource so each record shows a scannable shareable link, and add the matching column so the code appears in the table. This is a config-level change on a surface you already own, it has an obvious acceptance test (scan it with your phone from the browser), and it validates your GD/Imagick assumption in the real runtime within an hour.
Second use case, when you need it outside the panel: a Blade component dropped into a view or a PDF template, rendering the same payload.
Timebox: half a day to a working field plus a wrapper. The real cost is not ramp-up — it's the payload policy, the caching decision, and a physical-device scan check in QA.
Wrap it, don't leak it. No Blade template or domain class should reference the package namespace. One seam, two methods:
namespace App\Support;
use Illuminate\Support\Facades\Cache;
class Qr
{
public function __construct(private array $defaults = []) {}
/** PNG/SVG bytes, cached by payload + options hash. */
public function bytes(string $payload, array $options = []): string
{
$options = array_merge(config('qr.defaults', []), $this->defaults, $options);
$key = 'qr:'.hash('xxh128', $payload.'|'.json_encode($options));
return Cache::remember($key, now()->addDay(), function () use ($payload, $options) {
// single call site into lara-zeus/qr — swap target if we ever leave
return $this->render($payload, $options);
});
}
public function dataUri(string $payload, array $options = []): string
{
return 'data:image/png;base64,'.base64_encode($this->bytes($payload, $options));
}
}
That seam is the whole architectural point. It caps the cost of a future swap at an afternoon, and it's where you enforce the payload cap, the minimum module size, and the "payload is public" rule.
Filament form field. Add the field next to the URL/slug field it encodes, and make it read-only and reactive. The field's value comes from a sibling field, so the pattern is "field A holds the URL, field B displays the code for it":
// resource schema — confirm exact class names from src/
TextInput::make('share_url')->live(onBlur: true),
QrField::make('qr_preview')
->label('Scan to open')
->state(fn ($get) => $get('share_url'))
->size(200)
->margin(2),
Filament column. For existing records where the payload is derived rather than stored, use the column variant with a closure. Keep it out of table listings you paginate heavily — every row is a render.
Render at write time, serve from storage. In-request rendering is synchronous and CPU-bound. For anything beyond a low-volume admin surface, generate once on saved and store:
static::saved(function (Asset $asset) {
if ($asset->wasChanged(['share_url'])) {
Storage::disk('public')->put(
"qr/assets/{$asset->id}.png",
app(Qr::class)->bytes($asset->share_url)
);
}
});
Then serve a plain URL — CDN-able, cache-header-able, and gone from the hot path. The cache-keyed wrapper above gets you the same effect lazily; pick one and be consistent, because two caching layers with different keys is a debugging trap.
Three delivery shapes, three tradeoffs. Know which you're choosing:
data: URI — convenient in Blade, but inflates transfer by roughly a third and is blocked by some mail clients. Fine for one code on a page; don't do it in a 50-row table.Payload builders as value objects. vCard, WiFi, mailto:, tel:, deep links, and payment payloads all have brittle escaping rules. Keep them as small classes that return a string, and unit-test the escaping separately from the render:
final class VCardPayload
{
public function __toString(): string { /* \n line endings, CRLF, escaping */ }
}
This keeps the QR library out of your payload-format problems, which are where the real bugs are.
Config-as-defaults, not per-call-site. Set size, margin, foreground/background, and error correction once in your own config and let call sites override only what they must. Twenty call sites each passing ->size(300) is twenty places to change when brand colours move.
Testing pattern that actually catches the failure modes. Don't assert HTTP 200:
$res = $this->get("/qr/assets/{$asset->id}");
$res->assertHeader('Content-Type', 'image/png');
$this->assertGreaterThan(500, strlen($res->getContent())); // byte-length sanity
$this->assertSame("\x89PNG", substr($res->getContent(), 0, 4)); // magic bytes
Then add one round-trip test: decode the rendered image with a decoder (zxing-cpp CLI or a PHP decoder package) and assert the payload comes back byte-identical — including a URL-unsafe payload with &, ?, and unicode. That test is the only thing that catches an encoding regression, because those fail silently.
Pin ^3.0.1 and audit for 3.0.0. Also worth a note in your upgrade runbook: the encoding fix means rendered bytes may differ from anything you generated on 3.0.0. Stored fixtures and any pre-generated images should be regenerated, not trusted.
GD/Imagick is a runtime dependency you will forget in one place. It's almost always the queue worker or the CI image, and it always surfaces as a failed job or a green build that ships a broken feature. Add it to your container build check.
Payload length is your scan-failure budget. Longer payloads mean denser modules and worse real-world scanning, especially on cheap Android cameras and printed labels. Enforce a cap in the wrapper, and remember error correction is a dial: raise it for print and physical handling, keep it modest for screen-only codes where you need the density down.
Print needs different numbers than screens. Minimum module size of a few millimetres, a real quiet zone (margin) around the code, and high-contrast colours. A code that scans perfectly on a retina monitor at size(150) will fail on a laser-printed asset tag.
Colour inversion kills scanners. Light foreground on a dark background, or a low-contrast brand palette, is a common aesthetic decision that silently breaks the feature. Dark modules, light background, contrast checked on a physical phone.
Treat every payload as public. Codes get screenshotted, photographed over shoulders, and pasted into Slack. Never encode a secret, a long-lived token, or a value that grants access without expiry. If it must be sensitive, encode a short-lived signed URL, not the secret.
Cache invalidation is on you. A cached QR keyed by payload is correct-by-construction only if you include every option that affects output in the key. Miss one (colours, margin) and you serve a stale design forever. Hash the whole options array.
Filament reactivity can burn CPU. A live field re-renders the code on every keystroke of the URL input. Use onBlur, a debounce, or a "generate" action button. Watch for the same in table columns on paginated listings.
Don't fight the package for branded output. If the requirement becomes logo embedding, styled modules, or barcodes/DataMatrix, you've left this package's lane. That's a swap-behind-the-wrapper conversation, not a hack-the-view conversation. Prefer SVG where the surface allows it — it scales cleanly for print and sidesteps raster sizing entirely.
Extension points you actually have: published config for defaults, published views for markup, and your own wrapper for everything else (payload policy, caching, storage). Those three cover almost all customization without forking.
Vendor risk is a real, stated item. One maintainer, 58 stars, zero dependents. That's acceptable for commodity plumbing and disqualifying as a strategic dependency. The mitigations are the wrapper, the hard pin, and a lightweight recurring check of patch releases — plus periodically reading which encoder version is pinned, since the release notes don't surface it. Enable automated patch updates, but keep the encoder version visible in review so a transitive bump doesn't arrive invisibly.
Add a device-scan gate before any release that touches QR output. One engineer, one iPhone, one Android, one printout. It takes five minutes and it's the only check that validates the thing your users actually do with the feature.
How can I help you explore Laravel packages today?