milon/barcode
Generate and embed barcodes in Laravel with milon/barcode. It supports 20+ symbologies including Code 128, EAN-13, UPC-A, MSI, and POSTNET, and renders to HTML, PNG, JPG, SVG, or PDF through a simple facade and Blade-friendly API.
Install, publish config, confirm GD.
composer require milon/barcode
php artisan vendor:publish --provider="Milon\Barcode\BarcodeServiceProvider"
php -m | grep -i gd
DNS1D and DNS2D are auto-discovered on Laravel 9+; on older apps register Milon\Barcode\BarcodeServiceProvider plus the DNS1D/DNS2D facade aliases in config/app.php. Publish config/barcode.php once and commit it — it holds the store_path used by the *Path() helpers, and leaving it to a deploy-time publish step is an avoidable source of "works in staging" bugs.
Where to look first.
vendor/milon/barcode/src/Milon/Barcode/DNS1D.php and DNS2D.php — the real method signatures for your pinned version. The PNG background-colour argument only arrived in v12.1.0, and the ordering of showCode/colour/path parameters has moved across majors. Read the source, not a blog post.config/barcode.php — store_path, the only real configuration surface.C128; that release added GS1-128 format validation.NOASSERTION while the TCPDF-derived lineage is LGPL-3.0. Resolve this before the code is distributed to customers, not after.First use case: a SKU label in a Blade view.
{{-- Inline PNG as a data URI: no route, no storage, no queue --}}
<img src="data:image/png;base64,{{ DNS1D::getBarcodePNG($product->sku, 'C128', 2, 60) }}"
alt="{{ $product->sku }}">
{{-- Inline SVG: sharper for print, no GD dependency --}}
{!! DNS1D::getBarcodeSVG($product->sku, 'C128', 2, 60, [0, 0, 0], true) !!}
{{-- QR for a ticket or deep link --}}
<img src="data:image/png;base64,{{ DNS2D::getBarcodePNG($ticket->url, 'QRCODE', 6, 6) }}">
That is a ten-minute integration. Everything below is what turns it into something you'd actually ship and maintain.
The highest-leverage decision you'll make with this package. A single abstraction file contains the LGPL question, gives you one place for caching and validation, and reduces a future migration to one class plus a regression suite.
namespace App\Support;
use Illuminate\Support\Facades\Cache;
use Milon\Barcode\Facades\DNS1D;
use Milon\Barcode\Facades\DNS2D;
final class BarcodeRenderer
{
public static function png(string $data, string $type, array $o = []): string
{
$opts = array_merge([
'width' => 2, 'height' => 60,
'color' => [0, 0, 0], 'bg' => null, 'showCode' => true,
], $o);
$key = 'barcode:'.md5(serialize([$data, $type, $opts]));
return Cache::rememberForever($key, fn () => self::isTwoD($type)
? DNS2D::getBarcodePNG($data, $type, $opts['width'], $opts['height'], $opts['color'])
: DNS1D::getBarcodePNG($data, $type, $opts['width'], $opts['height'],
$opts['color'], $opts['showCode'], $opts['bg']));
}
public static function svg(string $data, string $type, array $o = []): string { /* same shape */ }
private static function isTwoD(string $type): bool
{
return in_array(strtoupper($type), ['QRCODE', 'PDF417', 'DATAMATRIX'], true);
}
}
Cache-key on the full option tuple, not just the payload — otherwise a width tweak serves stale dimensions forever.
A data URI inflates response size ~33% and re-renders on every page view. A cached route gives you real HTTP caching and keeps the binary out of your HTML.
Route::get('/barcodes/{sku}.png', function (string $sku) {
$png = BarcodeRenderer::png($sku, 'C128', ['width' => 2, 'height' => 60]);
return response($png, 200, [
'Content-Type' => 'image/png',
'Cache-Control' => 'public, max-age=31536000, immutable',
'Content-Disposition' => 'inline; filename="'.$sku.'.png"',
]);
})->where('sku', '[A-Za-z0-9\-]{1,64}');
response($binary) — never echo from a controller, and never return binary from an action that also renders a view. Constrain the route pattern, not just the payload downstream.
$path = storage_path("app/labels/{$order->id}.png");
DNS1D::getBarcodePNGPath($order->tracking_number, 'C128', 3, 100, [0, 0, 0], $path);
For DomPDF and Dompdf-likes: PNG, not SVG. SVG support in HTML-to-PDF engines is partial and fails by producing blank or half-drawn bars — which you won't notice until a warehouse scanner rejects the sheet. Base64 PNG in a data: URI inside the PDF template is the reliable route.
$pdf = PDF::loadView('invoices.show', [
'barcode' => 'data:image/png;base64,'.base64_encode(
BarcodeRenderer::png($invoice->number, 'C128', ['height' => 80])
),
]);
Only GS1-128 has meaningfully landed upstream validation (v13.5.1). Everything else trusts you completely:
$rules = match ($type) {
'EAN13' => ['required', 'digits:12'], // TCPDF appends the check digit
'EAN8' => ['required', 'digits:7'],
'UPCA' => ['required', 'digits:11'],
'C39' => ['required', 'regex:/^[0-9A-Z\-. $\/+%]{1,40}$/'],
'C128' => ['required', 'regex:/^[\x20-\x7E]{1,80}$/'],
'QRCODE' => ['required', 'string', 'max:1200'],
};
Two rules that prevent real incidents: never let a user-supplied "your reference" field reach a closed symbology like EAN/UPC — pick the type from your own domain logic, not from the form; and cap payload length, because a 4 KB QR code or a 5,000-module Code 128 is a cheap CPU and memory amplification vector.
Single renders are cheap; a 500-label batch inside a request is not. GD allocates per image, so you'll brush memory limits and the web timeout together. Offload to a job that writes PNGs to disk, zips them, then notifies.
class GenerateLabelSheet implements ShouldQueue
{
public function handle(): void
{
$zip = new ZipArchive;
// one DNS1D::getBarcodePNGPath() per row into a temp dir
}
}
Because the job and the web request share Cache::rememberForever keys via the wrapper, a label already previewed in the UI costs nothing when the batch runs.
| Need | Use | Note |
|---|---|---|
| Internal SKU, invoice number | C128 |
Full ASCII, compact |
| Retail packaging (EU) | EAN13 |
Supply 12 digits; check digit appended |
| Retail packaging (US) | UPCA |
Supply 11 digits |
| Asset tags, harsh environments | C39 |
Alphanumeric only; C39+/C39E for extended |
| Ticket, deep link, vCard | QRCODE (DNS2D) |
Size by payload length, not by width alone |
| Shipping manifest, dense data | PDF417 (DNS2D) |
High capacity |
A wrong check digit or a too-narrow module still produces a pretty image. Pin the behaviour that matters:
public function test_sku_barcode_renders_as_png(): void
{
$png = BarcodeRenderer::png('SKU-1234', 'C128');
$this->assertStringStartsWith("\x89PNG", $png);
[$w, $h] = getimagesizefromstring($png);
$this->assertGreaterThan(100, $w);
}
public function test_rejects_invalid_ean13_input(): void
{
$this->post('/labels', ['type' => 'EAN13', 'value' => 'ABC'])
->assertSessionHasErrors('value');
}
Assert on magic bytes and dimensions, never on exact pixel output — that changes with the GD version and with package minors.
Method signatures drift between majors. The background-colour argument on getBarcodePNG() arrived in v12.1.0; other parameter orderings and the *Path() helper signatures have shifted too. Pin the constraint ("milon/barcode": "^13.5") and read vendor/ for the version you actually installed. Note the version line tracks Laravel majors — v12.0.0 was a Laravel 12 compatibility release — so a Laravel upgrade implies a package major. Treat it as a code change, not a routine composer update.
ext-gd is mandatory for PNG only. getBarcodePNG() and getBarcodePNGPath() fail without it; getBarcodeSVG() and getBarcodeHTML() work fine. That's a useful escape hatch, but the real fix is docker-php-ext-install gd in your base image plus an assertion in a health check. Missing GD on a customer-hosted deployment is the most common "works on my machine" failure with this package.
Colours are RGB arrays, not hex. [0, 0, 0] works; '#000000' does not. The background argument is newer than the foreground one and its default may not be the transparency you assumed — set it explicitly when compositing onto coloured label stock.
getBarcodeHTML() returns raw markup. It emits styled <div> bars and, with showCode, the human-readable label containing your payload. If any part of that payload came from a user or a partner, you have an injection surface. Use SVG or PNG for untrusted data; only use {!! !!} with a payload you generated or validated against a strict character class.
Module width vs. print DPI is where scan failures actually live. The default width = 2 looks fine on a monitor and fails on a 300 dpi thermal printer. Decide your minimum module width in millimetres, convert to pixels for the target DPI, and verify with the real scanner on the real label stock. This is the step teams skip and then debug for a week.
Respect quiet zones and don't let CSS resize the symbol. Leave whitespace around the code; make sure label padding isn't collapsing to zero; and never put max-width: 100% on a barcode <img> inside a responsive container — it will silently rescale the module width below scannable on narrow viewports.
EAN/UPC check digits are a data-model decision. TCPDF computes the check digit when you supply 12 digits (EAN-13) or 11 (UPC-A). Whether your stored SKU already contains it is a convention you must pick, enforce in a validation rule, and cover with a test against a known-good sample. Mixed conventions in one table produce barcodes that are one digit off and completely unscannable.
Debugging a blank or malformed barcode, in order: dd(DNS1D::getBarcodeSVG(...)) to see whether the symbol actually contains bars; getimagesizefromstring() for zero dimensions; confirm GD is loaded; confirm the payload passes its symbology's character class. Nine times out of ten it's an invalid character, a zero width, or a missing check digit — not a rendering bug.
Cache aggressively, key on the full tuple. The individual render is cheap; the aggregate of identical renders for 2,000 SKUs across every page view is not. Include type, width, height, colour, and showCode in the key.
Extension points are thin by design. There is no plugin or driver system. The extension surface is the facade plus config/barcode.php's store_path. That's fine — build caching, validation, format selection, and queuing in your own wrapper and leave this package as a pure (data, type, dimensions) → bytes function.
Maintenance reality. The project is alive — PHP 8.6 in CI, GS1-128 validation in v13.5.1, Laravel 12 support in v12.0.0 — but it's a one-or-two-person effort with no commercial backer and no vendor SLA. The wrapper from §1 costs you a day and turns a future migration into a one-class change plus your existing regression suite. Also verify the current version against Packagist directly before pinning; some published release metadata for this package reads as unreliable.
How can I help you explore Laravel packages today?