DEV Community

Cover image for PortFlow: Bridging Laravel with Serial Hardware, IoT Sensors & Thermal Printers
Hamdy ELbatal
Hamdy ELbatal

Posted on

PortFlow: Bridging Laravel with Serial Hardware, IoT Sensors & Thermal Printers

PortFlow: Bridging Laravel with the Physical World — Serial Hardware, IoT Sensors & Thermal Printers

How to connect barcodes scanners, RFID readers, fingerprint modules, thermal printers, RS-232 scales, and Arduino/ESP32 microcontrollers to a Laravel application — through a clean, driver-based architecture and the browser's Web Serial API.


Most Laravel packages live entirely in software. They deal with HTTP, databases, queues, and caches — all safely inside the machine.

PortFlow does not.

It bridges the gap between physical hardware and Laravel's event system and Eloquent database, turning raw serial bytes into typed, routable value objects that your application can consume like any other event — with full PHPStan level 8 type safety, test coverage, and production-grade security hardening.

If you've ever tried to integrate a USB barcode scanner, an RS-232 industrial scale, or an ESP32 microcontroller with a Laravel application and ended up with a mess of raw socket code and hard-to-test conditionals — this article is for you.


The Problem: Hardware is Hard to Integrate with Web Frameworks

Serial devices — printers, sensors, scanners, microcontrollers — speak in bytes, not JSON. They use protocols designed decades before REST APIs existed: raw delimited strings, binary packets with checksums, ESC/POS command sequences, RS-232 semicolon-delimited records.

Bridging that world with Laravel typically means:

  • Raw fopen() / stream_socket_client() calls sprinkled across controllers
  • No abstraction — the protocol is baked into the business logic
  • No testability — you can't unit test a class that opens /dev/ttyUSB0
  • No type safety — parsed data is array<string, mixed> at best, a raw string at worst
  • No event routing — you have to wire the parsed data to your domain manually

PortFlow solves all of this with a layered architecture and a single clean interface.


Architecture: Clean Layers, Zero Coupling

PortFlow follows Clean Architecture. The domain has zero knowledge of Laravel infrastructure, HTTP, or serial ports. This makes every layer independently testable.

Browser (Web Serial API)
       │
       │  POST /portflow/ingest  { driver: "raw-json", chunk: "...", context: {...} }
       ▼
IngestController
       │
       ▼
PortFlowManager → DriverRegistry → SerialDriver::parseInbound()
       │
       ▼
SerialFrame[]  ──→  MessageRouter
                         │
                    ┌────┴─────┐
                    │          │
               Dispatch    Persist to
               Laravel    Eloquent
               Event      Model
Enter fullscreen mode Exit fullscreen mode

The source tree maps directly onto the layers:

src/
├── Domain/                       ← Pure PHP, zero Laravel dependencies
│   ├── Contracts/
│   │   ├── SerialDriver.php      ← Interface all drivers implement
│   │   └── SerialEvent.php       ← Marker interface for domain events
│   ├── DTO/
│   │   └── SerialFrame.php       ← Immutable value object: parsed frame
│   └── Services/
│       └── IoTFrameBuffer.php    ← Byte-stream accumulation
│
├── Application/                  ← Orchestration, no I/O
│   ├── Jobs/
│   │   └── RouteSerialFrameJob.php
│   └── Services/
│       ├── DriverRegistry.php
│       ├── HardwareMessageService.php
│       └── MessageRouter.php
│
├── Infrastructure/               ← All Laravel-specific code
│   ├── Drivers/                  ← Six built-in drivers
│   ├── Http/Controllers/
│   ├── Livewire/
│   └── Printing/
│
└── Console/Commands/
Enter fullscreen mode Exit fullscreen mode

Installation

composer require hamzi/portflow
Enter fullscreen mode Exit fullscreen mode

Publish the config:

php artisan vendor:publish --tag=portflow-config
Enter fullscreen mode Exit fullscreen mode

Publish the JavaScript bridge:

php artisan vendor:publish --tag=portflow-assets
Enter fullscreen mode Exit fullscreen mode

That's it. No database migrations, no required environment variables — just a config file and an optional JS asset.


The Web Serial Bridge

The key insight behind PortFlow is delegating the raw serial I/O to the browser's Web Serial API, which runs in Chrome/Edge 89+. The JavaScript bridge reads bytes from the serial port and forwards them to your Laravel backend in small chunks.

<!-- Include the bridge -->
<script src="{{ asset('vendor/portflow/portflow-serial.js') }}"></script>

<!-- Drop the Livewire component -->
<livewire:portflow-connector
  :baud-rate="115200"
  driver="raw-json"
  :auto-connect-on-load="true"
  :context="['device' => 'station-a']"
/>
Enter fullscreen mode Exit fullscreen mode

Or use the bridge directly with JavaScript:

const bridge = new PortFlowBridge({
  ingestUrl: '{{ route("portflow.ingest") }}',
  driver:    'raw-json',
  baudRate:  115200,
  csrfToken: document.querySelector('meta[name="csrf-token"]').content,
  autoReconnect: true,
  maxRetries: 5,
});

document.getElementById('connect').addEventListener('click', () => bridge.connect());
Enter fullscreen mode Exit fullscreen mode

The bridge supports auto-reconnect with exponential back-off (2 s → 4 s → 8 s → …), localStorage baud rate persistence, Alpine.js bindings, and real-time window events for every status change.


Six Built-in Drivers

Each driver implements a single interface:

interface SerialDriver
{
    public function name(): string;
    public function configure(array $options = []): void;

    /** @return array<int, SerialFrame> */
    public function parseInbound(string $chunk, array $context = []): array;

    public function encodeOutbound(array|string $payload): string;
}
Enter fullscreen mode Exit fullscreen mode

1. Raw / JSON Driver — ESP32 & Arduino

The most flexible driver. Accumulates bytes in an IoTFrameBuffer and emits complete SerialFrame objects when a newline delimiter arrives. Invalid JSON is never silently dropped — it's forwarded as {"raw": "..."} so you can decide how to handle it.

// Incoming from an ESP32:
{ "type": "barcode.scan", "barcode": "4006381333931" }
{ "type": "temperature", "celsius": 22.4, "humidity": 61 }
Enter fullscreen mode Exit fullscreen mode

Configure in config/portflow.php:

'default_driver' => 'raw-json',

'driver_options' => [
    'raw-json' => [
        'delimiter' => "\n",
        'max_bytes'  => 16384,
    ],
],

'mappings' => [
    [
        'driver'              => 'raw-json',
        'payload_field'       => 'type',
        'equals'              => 'barcode.scan',
        'event'               => \App\Events\ProductScanned::class,
        'event_payload_field' => 'barcode',
    ],
    [
        'driver'  => 'raw-json',
        'payload_field' => 'type',
        'equals'  => 'temperature',
        'model'   => \App\Models\SensorReading::class,
    ],
],
Enter fullscreen mode Exit fullscreen mode

2. Barcode Line Driver — USB/TTL Scanners

USB barcode scanners in HID keyboard mode or TTL mode emit a barcode string followed by CR, LF, or CR+LF. This driver strips terminators, filters empty lines, and emits one SerialFrame per barcode.

'driver_options' => [
    'barcode-line' => [
        'delimiter' => "\n",
        'prefix'    => null,    // strip a leading prefix if present
    ],
],
Enter fullscreen mode Exit fullscreen mode

Inbound SerialFrame::$payload:

[
    'barcode'  => '4006381333931',
    'raw'      => "4006381333931\n",
    'driver'   => 'barcode-line',
    'scanned_at' => '2026-05-05T14:23:11+00:00',
]
Enter fullscreen mode Exit fullscreen mode

3. RFID ASCII Driver — STX/ETX Readers

Standard ASCII RFID readers wrap tag IDs in STX (0x02) / ETX (0x03) delimiters. The driver buffers incomplete frames across multiple chunks (essential for real TCP/serial fragmentation), uppercases tag IDs by default, and discards empty tags automatically.

'driver_options' => [
    'rfid-ascii' => [
        'stx'       => "\x02",
        'etx'       => "\x03",
        'uppercase' => true,
    ],
],
Enter fullscreen mode Exit fullscreen mode

Parsed SerialFrame::$payload:

[
    'tag_id'  => 'E2801160600002044E35A000',
    'raw'     => "\x02E2801160600002044E35A000\x03",
    'driver'  => 'rfid-ascii',
]
Enter fullscreen mode Exit fullscreen mode

4. Fingerprint Packet Driver — Binary UART Modules

Binary fingerprint modules (e.g., R305, FPM10A) send structured binary packets over UART. This is the most complex driver — it parses a multi-byte packet header, validates the checksum, and returns typed frames for command, acknowledgement, data, and end-of-data packet types.

Packet layout:
┌──────────┬──────────┬──────────┬────────────────┬──────────┬──────────────┐
│ Start(2) │ Type(1)  │ Len(2)   │ Payload(Len-2) │ Chk(2)  │              │
└──────────┴──────────┴──────────┴────────────────┴──────────┴──────────────┘
Enter fullscreen mode Exit fullscreen mode

Checksum mismatches are logged with Log::warning including the raw hex for debugging. Payload bytes are base64-encoded for binary-safe cache storage across chunks.

'driver_options' => [
    'fingerprint-packet' => [
        'start_code' => 0xEF01,
    ],
],
Enter fullscreen mode Exit fullscreen mode

5. ESC/POS Driver — Thermal Printers

The ESC/POS driver handles both directions:

Inbound (USB barcode scanner as keyboard input):

// Scanner sends: "4006381333931\n"
// → SerialFrame with payload['barcode'] = '4006381333931'
Enter fullscreen mode Exit fullscreen mode

Outbound (print a Blade template as ESC/POS bytes):

$bytes = PortFlow::print('receipts.order', ['order' => $order]);
// Returns ESC/POS byte string — send to printer via Web Serial
Enter fullscreen mode Exit fullscreen mode

Build ESC/POS bytes with the fluent builder:

use Hamzi\PortFlow\Infrastructure\Printing\EscPosBuilder;

$bytes = (new EscPosBuilder)
    ->align('center')
    ->bold()
    ->text('ACME STORE')
    ->bold(false)
    ->divider()
    ->align('left')
    ->text('Item 1 ................. $9.99')
    ->text('Item 2 ................. $4.50')
    ->divider()
    ->bold()
    ->text('TOTAL ................. $14.49')
    ->bold(false)
    ->feed(3)
    ->cut()
    ->bytes();
Enter fullscreen mode Exit fullscreen mode
Method ESC/POS command Description
text(string $v) Append a line
bold(bool $on) ESC E n Toggle bold
underline(bool $on) ESC - n Toggle underline
align(string) ESC a n left, center, right
divider(int $w) Dash separator
feed(int $lines) LF Blank lines
cut(bool $partial) GS V Paper cut

6. RS-232 Driver — Industrial Scales & Legacy Devices

Industrial scales and legacy serial devices often emit semicolon-delimited ASCII records. The RS-232 driver parses these and exposes both the raw string and the split segments.

// Device emits:
12.500;kg;SCALE-A1

// SerialFrame::$payload:
[
    'weight'   => '12.500',
    'unit'     => 'kg',
    'segments' => ['12.500', 'kg', 'SCALE-A1'],
    'raw'      => '12.500;kg;SCALE-A1',
]
Enter fullscreen mode Exit fullscreen mode

Encode outbound commands (e.g., send a TARE command):

PortFlow::encode('rs232', ['TARE', '0', 'RESET']);
// → "TARE,0,RESET\n"
Enter fullscreen mode Exit fullscreen mode

Hardware → Laravel Events (The Magic)

Once frames are parsed, MessageRouter maps them to domain events and Eloquent models using the portflow.mappings config:

// config/portflow.php
'mappings' => [
    [
        'driver'              => 'barcode-line',
        'event'               => \App\Events\ProductScanned::class,
        'event_payload_field' => 'barcode',
    ],
    [
        'driver' => 'rfid-ascii',
        'event'  => \App\Events\AccessCardSwiped::class,
        'event_payload_field' => 'tag_id',
    ],
    [
        'driver' => 'raw-json',
        'model'  => \App\Models\SensorReading::class,
    ],
],
Enter fullscreen mode Exit fullscreen mode

Your listener looks exactly like any other Laravel event listener:

class HandleBarcodeScan
{
    public function handle(ProductScanned $event): void
    {
        $product = Product::where('barcode', $event->frame->payload['barcode'])->firstOrFail();
        $product->increment('scan_count');
        broadcast(new ProductDisplayed($product));
    }
}
Enter fullscreen mode Exit fullscreen mode

Queue-Based Routing

High-throughput hardware (RS-232 at 921600 baud, streaming sensors) can overwhelm synchronous routing. Enable queue routing with one config line:

'queue_routing' => true,
'queue_connection' => 'redis',
'queue_name'       => 'hardware',
Enter fullscreen mode Exit fullscreen mode

Frames are dispatched to RouteSerialFrameJob and processed asynchronously. Rate limiting is built in — default 60 requests/minute per IP + authenticated user, configurable in portflow.ingest_rate_limit.


IoT Frame Buffering: Handling Fragmented Streams

Serial bytes don't arrive in neat JSON objects. A 64-byte JSON frame might arrive as three 22-byte chunks depending on timing, baud rate, and OS buffering. IoTFrameBuffer solves this transparently:

Chunk 1: '{"type":"bar'
Chunk 2: 'code.scan","b'
Chunk 3: 'arcode":"400638"}\n'
         └──────────── emitted as SerialFrame ──────────────┘
Enter fullscreen mode Exit fullscreen mode

The buffer persists across requests using Laravel Cache (keyed by context.session_id), so even a single JSON frame split across multiple HTTP requests from the Web Serial bridge is assembled correctly.

// config/portflow.php
'driver_options' => [
    'raw-json' => [
        'max_bytes' => 16384,   // rolling ceiling — prevents unbounded growth
    ],
],
Enter fullscreen mode Exit fullscreen mode

Backend Serial Mode: portflow:listen

For server-side integration (Linux services, kiosks, headless stations), PortFlow provides a backend Artisan listener that reads from real serial ports and feeds frames through the same ingest pipeline:

# Basic
php artisan portflow:listen /dev/ttyUSB0 --driver=barcode-line --baud=115200

# Full UART parameters
php artisan portflow:listen /dev/ttyUSB1 \
  --driver=rfid-ascii \
  --baud=9600 \
  --parity=none \
  --data-bits=8 \
  --stop-bits=1 \
  --flow-control=none \
  --context='{"station":"gate-a"}'

# Debug mode — shows raw payload in the console
php artisan portflow:listen /dev/ttyUSB0 \
  --driver=raw-json \
  --baud=921600 \
  --show-data=1 \
  --show-data-format=json
Enter fullscreen mode Exit fullscreen mode

Device path validation ensures only legitimate TTY nodes are used (/dev/ttyXxx, /dev/serial/by-id/*, /dev/serial/by-path/*) — path traversal via writable /dev/shm/ or similar paths is rejected at the validation layer.


Creating a Custom Driver

Generate the boilerplate with Artisan:

php artisan portflow:make-driver MyScale
# creates app/SerialDrivers/MyScaleDriver.php
Enter fullscreen mode Exit fullscreen mode

The generated stub implements SerialDriver. Fill in parseInbound() and encodeOutbound():

final class MyScaleDriver implements SerialDriver
{
    public function name(): string { return 'my-scale'; }

    public function configure(array $options = []): void { }

    public function encodeOutbound(array|string $payload): string
    {
        return is_string($payload) ? $payload : implode(',', (array) $payload)."\n";
    }

    /** @return array<int, SerialFrame> */
    public function parseInbound(string $chunk, array $context = []): array
    {
        $weight = trim($chunk);
        if ($weight === '') {
            return [];
        }

        return [SerialFrame::now($this->name(), ['weight' => $weight], $context)];
    }
}
Enter fullscreen mode Exit fullscreen mode

Register it in config/portflow.php:

'drivers' => [
    'my-scale' => \App\SerialDrivers\MyScaleDriver::class,
],
Enter fullscreen mode Exit fullscreen mode

Define a typed domain event by implementing SerialEvent:

use Hamzi\PortFlow\Domain\Contracts\SerialEvent;

final class WeightReceived implements SerialEvent
{
    public function __construct(
        public readonly string $value,
        public readonly array  $context = [],
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

The SerialEvent marker interface is enforced at routing time. Events that don't implement it are rejected with Log::error and routing is skipped — preventing silent data loss from misconfigured mappings.


Security Hardening (v0.6.0)

Security was a primary concern in the latest release. Here's what was hardened:

1. Context Key Whitelisting (Injection Prevention)

The ingest endpoint previously accepted any keys in the context JSON object, making it possible to inject arbitrary data into the frame context that could reach Eloquent fill() calls.

// Now: only these keys are accepted. Unknown keys → 422 Unprocessable Entity.
'context' => ['nullable', 'array:session_id,source,device,baud_rate,device_name'],
'context.session_id'  => ['sometimes', 'string', 'max:128'],
'context.source'      => ['sometimes', 'string', 'max:64'],
'context.device'      => ['sometimes', 'string', 'max:128'],
'context.baud_rate'   => ['sometimes', 'integer'],
'context.device_name' => ['sometimes', 'string', 'max:128'],
Enter fullscreen mode Exit fullscreen mode

2. Reverse-Proxy Safe Rate Limiting

The default Laravel IP-based rate limit is trivially bypassed behind a proxy. PortFlow's rate limiter combines the client IP with the authenticated user ID:

RateLimiter::for('portflow', function (Request $request) {
    $by = $request->ip();

    if ($request->user() !== null) {
        $by .= '-'.$request->user()->getAuthIdentifier();
    }

    return Limit::perMinute((int) config('portflow.ingest_rate_limit', 60))->by($by);
});
Enter fullscreen mode Exit fullscreen mode

3. Boot-Time Configuration Validation

Misconfigured drivers (wrong class name, missing interface) now throw immediately at application boot — not silently at the first request, which could happen in production:

// In PortFlowServiceProvider::boot()
private function validateConfiguration(): void
{
    foreach ($drivers as $name => $class) {
        if (! class_exists($class)) {
            throw PortFlowException::invalidDriver($name, "class [{$class}] does not exist");
        }
        if (! is_a($class, SerialDriver::class, true)) {
            throw PortFlowException::invalidDriver($name, "class [{$class}] must implement SerialDriver");
        }
    }

    foreach ($mappings as $index => $mapping) {
        if (isset($mapping['event']) && ! class_exists($mapping['event'])) {
            throw PortFlowException::invalidConfiguration(
                "mappings[{$index}].event class [{$mapping['event']}] does not exist"
            );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Device Path Validation

The portflow:listen command validates device paths with a strict allowlist regex:

private function isValidDevicePath(string $device): bool
{
    // Linux/macOS: only real TTY nodes and stable symlinks
    if (str_starts_with($device, '/dev/')) {
        return (bool) preg_match(
            '#^/dev/(tty[A-Za-z0-9]+|serial/by-id/[A-Za-z0-9._-]+|serial/by-path/[A-Za-z0-9._:-]+)$#',
            $device
        );
    }
    // Windows: COM1–COM256 and extended \\.\COMx paths
    return (bool) preg_match('/^(COM[1-9]\d{0,2}|\\\\\\\\\\.\\\\COM[1-9]\d{0,2})$/i', $device);
}
Enter fullscreen mode Exit fullscreen mode

Testing: From 19 to 86 Tests

The v0.6.0 release added comprehensive test coverage that was previously missing.

Unit Tests — Every Driver

// tests/Unit/BarcodeLineDriverTest.php
it('strips CR LF terminators', function () {
    $driver = new BarcodeLineDriver;
    $frames = $driver->parseInbound("4006381333931\r\n");

    expect($frames)->toHaveCount(1)
        ->and($frames[0]->payload['barcode'])->toBe('4006381333931');
});

it('handles multiple barcodes in one chunk', function () {
    $driver = new BarcodeLineDriver;
    $frames = $driver->parseInbound("CODE1\nCODE2\nCODE3\n");

    expect($frames)->toHaveCount(3);
});
Enter fullscreen mode Exit fullscreen mode

Integration Tests — End-to-End

// tests/Feature/FrameRoutingIntegrationTest.php
it('routes a raw-json frame to a Laravel event', function () {
    Event::fake([ProductScanned::class]);

    $response = $this->postJson('/portflow/ingest', [
        'driver' => 'raw-json',
        'chunk'  => json_encode(['type' => 'barcode.scan', 'barcode' => 'TEST123'])."\n",
    ]);

    $response->assertOk();
    Event::assertDispatched(ProductScanned::class, fn ($e) => $e->frame->payload['barcode'] === 'TEST123');
});

it('rejects unknown context keys with 422', function () {
    $this->postJson('/portflow/ingest', [
        'driver'  => 'raw-json',
        'chunk'   => '{}',
        'context' => ['arbitrary_key' => 'injection_value'],
    ])->assertUnprocessable();
});

it('assembles a fragmented JSON frame across two requests', function () {
    $sessionId = 'test-buffer-123';

    $this->postJson('/portflow/ingest', [
        'driver'  => 'raw-json',
        'chunk'   => '{"type":"barcode.scan","bar',
        'context' => ['session_id' => $sessionId],
    ])->assertOk();

    $this->postJson('/portflow/ingest', [
        'driver'  => 'raw-json',
        'chunk'   => 'code":"4006381333931"}'."\n",
        'context' => ['session_id' => $sessionId],
    ])->assertOk();
    // Buffer assembled across two HTTP requests ✓
});
Enter fullscreen mode Exit fullscreen mode

Test results: 86 tests, 172 assertions, PHPStan level 8 — 0 errors.


Blade & Livewire Components

Status Badge

<livewire:portflow-status />
Enter fullscreen mode Exit fullscreen mode

Displays driver name, connection status, and frame count. Updates in real time via Livewire events.

Connector Component

<livewire:portflow-connector
  :baud-rate="115200"
  driver="barcode-line"
  :auto-connect-on-load="true"
  :filters="[['usbVendorId' => 6790]]"
  browser-chunk-event="scanner-frame"
/>
Enter fullscreen mode Exit fullscreen mode

Handles the full connect/disconnect/reconnect lifecycle. Emits Livewire events on every frame so you can respond with #[On('scanner-frame')] in any Livewire component.

Alpine.js Integration

<div x-data="portflowConnector()" x-init="init()">
  <button @click="connect()" :disabled="connecting">
    <span x-text="connected ? 'Connected' : 'Connect Device'"></span>
  </button>
  <span x-show="retryCount > 0" x-text="'Reconnecting… attempt ' + retryCount"></span>
</div>
Enter fullscreen mode Exit fullscreen mode

Supported Devices (Non-Exhaustive)

Category Devices / Modules
Microcontrollers ESP32, ESP8266, Arduino (Uno, Mega, Nano), Raspberry Pi (serial UART)
Barcode Scanners Any USB HID / TTL scanner (Honeywell, Zebra, Datalogic, generic)
RFID Readers STX/ETX ASCII readers (EM4100, MIFARE ASCII mode)
Fingerprint R305, FPM10A, AS608, any binary-UART module
Thermal Printers Any ESC/POS compatible printer (Epson TM-series, Bixolon, Star, Sewoo, generic USB)
Scales / Weighing RS-232 industrial scales (Mettler Toledo, Sartorius, Adam Equipment)
Legacy/Industrial Any RS-232 / RS-485 device with ASCII output

Requirements

Dependency Version
PHP ^8.2
Laravel ^11.0 | ^12.0 | ^13.0
Livewire ^3.0
Browser (Web Serial) Chrome 89+ / Edge 89+

Quick Reference: The Facade

use Hamzi\PortFlow\Facades\PortFlow;

// Parse inbound bytes with a driver
$frames = PortFlow::ingest('barcode-line', $rawChunk, $context);

// Encode outbound data
$bytes = PortFlow::encode('escpos', ['text' => 'Hello, printer!']);

// Render a Blade view as ESC/POS bytes
$bytes = PortFlow::print('receipts.order', compact('order'));

// Health check
$status = PortFlow::health();
// → ['default_driver' => 'raw-json', 'registered_drivers' => ['raw-json', 'barcode-line', ...]]
Enter fullscreen mode Exit fullscreen mode

Getting Started Today

# Install
composer require hamzi/portflow

# Publish config
php artisan vendor:publish --tag=portflow-config

# Publish JS bridge
php artisan vendor:publish --tag=portflow-assets

# Generate a custom driver
php artisan portflow:make-driver MyDevice

# Test from a real serial port
php artisan portflow:listen /dev/ttyUSB0 --driver=raw-json --baud=115200 --show-data=1
Enter fullscreen mode Exit fullscreen mode

Final Thoughts

PortFlow was built to close a real gap in the Laravel ecosystem. Hardware integration shouldn't require you to abandon Laravel's clean architecture, event system, and testability. With PortFlow, a barcode scan from a $15 USB scanner flows through the same event-driven pipeline as any other domain event in your application — typed, routable, testable, and secure.

If you're building a POS system, a warehouse scanning app, an access control panel, a smart factory dashboard, or any application that needs to talk to physical hardware — I'd love to hear what you're building.

Contributions are welcome. See CONTRIBUTING.md for the branching strategy, commit convention, and PR guidelines.


Published under MIT License — built with PHP 8.2, Laravel 11/12/13, and a lot of serial port debugging.


Tags: laravel php iot hardware serial-port barcode rfid thermal-printer clean-architecture open-source

Top comments (0)