feat(qr): QR code renderer (SVG + PNG), model, generate action

Implements QrRenderer using chillerlan/php-qrcode v6 API (outputInterface
instead of deprecated outputType constants), QrCode Eloquent model with
ULID auto-generation and soft deletes, and GenerateQrCode action.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
boban 2026-05-16 01:13:41 +02:00
parent dd89ee395c
commit d3b777d8e8
4 changed files with 148 additions and 0 deletions

View File

@ -0,0 +1,32 @@
<?php
namespace App\Domains\QrCode\Actions;
use App\Domains\QrCode\Models\QrCode;
use App\Domains\QrCode\Services\QrRenderer;
use App\Models\User;
class GenerateQrCode
{
public function __construct(private readonly QrRenderer $renderer) {}
public function handle(
int $workspaceId,
string $type,
array $payload,
array $style,
User $creator,
?int $linkId = null,
): QrCode {
$data = $this->renderer->buildPayload($type, $payload);
return QrCode::create([
'workspace_id' => $workspaceId,
'link_id' => $linkId,
'type' => $type,
'payload' => $payload,
'style' => $style ?: null,
'created_by' => $creator->id,
]);
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Domains\QrCode\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Support\Str;
class QrCode extends Model
{
use SoftDeletes, HasFactory;
protected $guarded = ['id'];
protected $casts = [
'payload' => 'array',
'style' => 'array',
'ulid' => 'string',
];
protected static function booted(): void
{
static::creating(function (self $model) {
if (empty($model->ulid)) {
$model->ulid = (string) Str::ulid();
}
});
}
protected static function newFactory(): \Database\Factories\QrCodeFactory
{
return \Database\Factories\QrCodeFactory::new();
}
public function workspace(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(\App\Domains\Workspace\Models\Workspace::class);
}
public function link(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(\App\Domains\Link\Models\Link::class);
}
public function creator(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'created_by');
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Domains\QrCode\Services;
use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;
use chillerlan\QRCode\Output\QRMarkupSVG;
use chillerlan\QRCode\Output\QRGdImagePNG;
class QrRenderer
{
public function toSvg(string $data, array $style): string
{
$options = new QROptions([
'outputInterface' => QRMarkupSVG::class,
'outputBase64' => false,
]);
return (new QRCode($options))->render($data);
}
public function toPngBase64(string $data, array $style): string
{
$options = new QROptions([
'outputInterface' => QRGdImagePNG::class,
'outputBase64' => true,
'scale' => 10,
]);
return (new QRCode($options))->render($data);
}
public function buildPayload(string $type, array $data): string
{
return match ($type) {
'url' => $data['url'],
'text' => $data['text'],
'wifi' => "WIFI:T:{$data['encryption']};S:{$data['ssid']};P:{$data['password']};;",
'vcard' => $this->buildVcard($data),
default => $data['url'] ?? '',
};
}
private function buildVcard(array $d): string
{
return "BEGIN:VCARD\nVERSION:3.0\nFN:{$d['name']}\nEMAIL:{$d['email']}\nTEL:{$d['phone']}\nEND:VCARD";
}
}

View File

@ -0,0 +1,18 @@
<?php
use App\Domains\QrCode\Services\QrRenderer;
it('generates valid SVG for URL type', function () {
$renderer = new QrRenderer;
$svg = $renderer->toSvg('https://example.com', []);
expect($svg)->toContain('<svg')
->toContain('</svg>');
});
it('generates png as base64', function () {
$renderer = new QrRenderer;
$png = $renderer->toPngBase64('https://example.com', []);
expect($png)->toStartWith('data:image/png;base64,');
});