chillerlan/php-qrcode
chillerlan/php-qrcode is a PHP QR Code library for generating and reading codes, based on Kazuhiko Arase's generator and a ZXing port. It supports Model 2 versions 1-40, L/M/Q/H error correction, mixed encoding modes, and configurable output.
composer require chillerlan/php-qrcode
Before you write a single line, check the PHP constraint against your app:
| Package line | Min PHP | Notes |
|---|---|---|
^5.0 |
7.4 / 8.0 | $outputType constants still exist |
^6.0 |
8.2 | outputInterface replaces outputType |
dev-main (v7 dev) |
8.4 | Asymmetric visibility, property hooks |
Pin an explicit constraint (^6.0), never dev-main without a commit hash — the main branch is active development for the next major and the examples in it will not compile against your installed release. The README is explicit about matching the examples folder to your version branch (v5.0.x, v6.0.x, main). This is the number one source of "why doesn't this work" issues.
The minimum viable call is a one-liner that returns a complete data URI:
echo '<img src="'.(new QRCode)->render($data).'" alt="QR Code" />';
QRCode::render() returns a string — for markup/image outputs this is a data: URI, for QRMarkupHTML it's an HTML fragment, for QRStringJSON it's JSON. The default output interface is SVG, which means you get a vector, resolution-independent, no GD dependency, and you can drop it straight into an <img src>, a PDF, or a Blade template.
examples/ folder on the matching branch — this is effectively the real API documentation.QROptions — the single most important class. It is a chillerlan/php-settings-container based options object; everything you configure lives here. Its documentation lives in the php-settings-container repo.QROutputInterface — when you need to know which class to put in $outputInterface.use chillerlan\QRCode\{QRCode, QROptions};
$options = new QROptions([
'eccLevel' => QRCode::ECC_H, // L / M / Q / H
'outputInterface' => QRMarkupSVG::class, // v6: class name, not a constant
'addQuietzone' => true,
'quietzoneSize' => 4,
'outputBase64' => false, // raw markup, not a data URI
]);
echo (new QRCode($options))->render('otpauth://totp/...');
Since v6 the QRCode constructor also accepts an iterable and wraps it in a QROptions internally, so new QRCode(['eccLevel' => QRCode::ECC_H]) works too.
QROptions is cheap to clone and expensive to misconfigure per-call. Build it once (per request or per config), reuse it:
class QrService
{
private QROptions $options;
public function __construct()
{
$this->options = new QROptions([
'eccLevel' => QRCode::ECC_M,
'scale' => 8,
'addQuietzone' => true,
'outputBase64' => false,
]);
}
public function svg(string $data): string
{
$o = clone $this->options;
$o->outputInterface = QRMarkupSVG::class;
return (new QRCode($o))->render($data);
}
public function png(string $data): string // needs ext-gd
{
$o = clone $this->options;
$o->outputInterface = QRGdImagePNG::class;
$o->outputBase64 = true;
return (new QRCode($o))->render($data);
}
}
Bind this as a singleton in the container; do not new QROptions inside a loop.
QR encoding is pure CPU. For anything user-facing, generate once and serve statically:
Route::get('/qr/{slug}.svg', function (string $slug) {
$path = storage_path("qr/{$slug}.svg");
if (!file_exists($path)) {
$svg = app(QrService::class)->svg(Url::to("/p/{$slug}"));
File::put($path, $svg);
}
return response()->file($path, ['Content-Type' => 'image/svg+xml']);
});
QRCode::render($data, $file) also accepts a second argument that writes to disk directly, which is handy for one-off CLI/batch generation:
(new QRCode($options))->render($payload, storage_path("qr/{$id}.png"));
If you need PNG, SVG and PDF for the same payload, keep the payload and options separate and re-render per format. Don't try to convert between outputs — the library generates from data, it does not transcode.
$formats = [
'svg' => QRMarkupSVG::class,
'png' => QRGdImagePNG::class,
'webp' => QRGdImageWEBP::class,
'pdf' => QRPdf::class, // requires setasign/fpdf
'eps' => QREps::class,
];
The reader is the best test harness for the generator. Assert that what you produce is actually scannable instead of asserting on byte-for-byte output:
use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;
use chillerlan\QRCode\QRCodeReader;
public function test_qr_is_readable(): void
{
$payload = 'https://example.com/p/abc?ref=qr';
$options = new QROptions([
'outputInterface' => QRGdImagePNG::class,
'outputBase64' => true,
'scale' => 10,
]);
$dataUri = (new QRCode($options))->render($payload);
$blob = base64_decode(substr($dataUri, strlen('data:image/png;base64,')));
$result = (new QRCodeReader())->readFromBlob($blob);
$this->assertSame($payload, $result->data);
}
This catches the real regressions: quiet zone removed, scale too small, ECC level silently changed, payload truncated.
use chillerlan\QRCode\QRCodeReader;
$result = (new QRCodeReader())->readFromFile($request->file('qr')->getRealPath());
$decoded = $result->data;
Guard this like any image upload: validate MIME/size first, wrap in try/catch (the detector throws on undecodable input), and note the reader requires ext-gd or ext-imagick — a separate requirement from the generator, which only needs ext-gd for raster output.
Tuning is done through QROptions, not by post-processing pixels:
$options = new QROptions([
'outputInterface' => QRGdImagePNG::class,
'drawCircularModules' => true,
'circleRadius' => 0.45,
'drawLightModules' => false,
'keepAsSquare' => [...], // finder patterns etc. by module value
'moduleValues' => [
// map module bitmask -> [R,G,B,A]
],
'imageTransparent' => false,
]);
For logos/watermarks, overlay after rendering (GD/Imagick or intervention/image) and raise ECC to H first — you're destroying modules, so you need the redundancy.
When you need HTML-with-classes, inline JSON for a frontend renderer, or a bespoke template:
class QrTailwindHtml extends QROutputAbstract
{
public static function moduleValueIsValid(mixed $value): bool { /* ... */ }
public function dump(mixed $matrix): string { /* ... */ }
}
Implement/extend QROutputAbstract, then set $options->outputInterface = QrTailwindHtml::class. This is the sanctioned extension point and is far more maintainable than string-replacing generated SVG.
$outputType no longer exists in v6. The constants on QROutputInterface were removed; you now pass a class-string via $options->outputInterface (default QRMarkupSVG::class). Any tutorial using QROutputInterface::MARKUP_SVG is pre-v6.
dev-main is not a release. The main branch targets v7 and requires PHP 8.4. If a snippet from the repo's main examples fails, you almost certainly installed ^6.0. Always match the examples branch to the installed version.
The getters are being retired. Since 6.0.1, QRMatrix::getMatrix(), getSize(), getVersion(), getEccLevel(), and ~18 others are deprecated in favour of public properties via PHP 8.4 asymmetric visibility ($matrix->matrix, $matrix->moduleCount, etc.). QRMatrix gets a magic getter as an upgrade path; the rest do not. Stop calling getMatrix() in new code.
getMatrix() vs getBooleanMatrix(). In v6 the $boolean parameter was removed from getMatrix() and a separate getBooleanMatrix() was extracted for type clarity. Code passing true here will break.
Base64 doubles your payload. outputBase64 => true turns a ~2KB SVG into a ~3KB data URI. For QRGdImage*, data URIs are convenient for <img>; for SVG served over a route, set it to false and emit image/svg+xml.
Never echo raw binary in HTML. PNG/WebP output is binary. Either set outputBase64 or write to a file and serve it.
Quiet zone and scale cause 90% of "it won't scan." Keep addQuietzone => true with quietzoneSize >= 4. For raster output, scale is pixels-per-module — 8–10 is a safe floor; a 500-character payload pushes you to a high version, more modules, and a physically denser code, so scale up proportionally. A code that renders fine at 200px on desktop can be unscannable in print.
ECC H costs capacity. Higher error correction means fewer data modules. If you're near the version ceiling with ECC_H, either shorten the payload (use a short URL redirect, not the full URL with tracking params) or drop to M/Q. Do the ECC choice before the logo overlay decision — those two constraints fight each other.
QROptions rejects unknown keys. It's a settings container with validation; typos in option names throw rather than being silently ignored. That's good, but it means copy-pasted option names from v5 docs ($outputType, $imageBase64, etc.) fail loudly at runtime.
Charset and encoding modes. Default is 8-bit binary/UTF-8, which handles most URLs and vCards. Kanji (Shift-JIS) and hanzi (GB2312/GB18030) use dedicated 13-bit double-byte modes, and ECI is supported for binary mode. Don't hand-roll Japanese text into a UTF-8 payload and expect mode optimisation — mixed-mode selection happens based on what you feed it, so feed it correctly encoded input.
The QROutputAbstract speed-cache properties are going away. $excludeFromConnect, $keepAsSquare, $connectPaths, $eol, $drawCircularModules, etc. will be removed from that class in v7 (replaced by property hooks / local re-addition). If you've subclassed QROutputAbstract and depend on them, read those from $this->options instead.
Optional dependencies are truly optional — until they aren't. QRPdf needs setasign/fpdf; QRImagick needs ext-imagick and ext-fileinfo; QRGdImage* needs ext-gd. Feature-detect and fall back to SVG rather than letting a production request fatal:
$interface = extension_loaded('gd') ? QRGdImagePNG::class : QRMarkupSVG::class;
Reader robustness. The ZXing port struggles with low-contrast, inverted, or transparent images. The reader has its own options trait (QRCodeReaderOptionsTrait) separate from QROptions — use it to force Imagick, grayscale, invert, or increase contrast before giving up on an upload. Also cap image dimensions before decoding; a 6000×6000 upload will happily eat your memory.
Debugging output shape. When an output looks wrong, dump $result[0..200] and check the prefix: data:image/svg+xml;base64, vs <?xml vs <!DOCTYPE html> vs { tells you instantly whether outputInterface took effect. Most "wrong format" bugs are an options object that got overwritten somewhere upstream.
Licensing. Apache-2.0, with the decoder being a PHP port of ZXing — keep the NOTICE file if you redistribute. Also note "QR Code" is a Denso Wave trademark; don't brand your feature as if you own the term.
Generate in a queue for bulk. Rendering tens of thousands of codes (event badges, ticket batches) belongs in a job with a chunked loop, not a web request. The library is fast, but you're still doing Reed–Solomon math in userland PHP.
How can I help you explore Laravel packages today?