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
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/
Installation
composer require hamzi/portflow
Publish the config:
php artisan vendor:publish --tag=portflow-config
Publish the JavaScript bridge:
php artisan vendor:publish --tag=portflow-assets
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']"
/>
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());
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;
}
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 }
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,
],
],
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
],
],
Inbound SerialFrame::$payload:
[
'barcode' => '4006381333931',
'raw' => "4006381333931\n",
'driver' => 'barcode-line',
'scanned_at' => '2026-05-05T14:23:11+00:00',
]
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,
],
],
Parsed SerialFrame::$payload:
[
'tag_id' => 'E2801160600002044E35A000',
'raw' => "\x02E2801160600002044E35A000\x03",
'driver' => 'rfid-ascii',
]
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) │ │
└──────────┴──────────┴──────────┴────────────────┴──────────┴──────────────┘
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,
],
],
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'
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
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();
| 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',
]
Encode outbound commands (e.g., send a TARE command):
PortFlow::encode('rs232', ['TARE', '0', 'RESET']);
// → "TARE,0,RESET\n"
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,
],
],
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));
}
}
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',
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 ──────────────┘
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
],
],
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
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
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)];
}
}
Register it in config/portflow.php:
'drivers' => [
'my-scale' => \App\SerialDrivers\MyScaleDriver::class,
],
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 = [],
) {}
}
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'],
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);
});
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"
);
}
}
}
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);
}
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);
});
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 ✓
});
Test results: 86 tests, 172 assertions, PHPStan level 8 — 0 errors.
Blade & Livewire Components
Status Badge
<livewire:portflow-status />
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"
/>
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>
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', ...]]
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
- GitHub: github.com/hamdyelbatal122/PortFlow
- Packagist: packagist.org/packages/hamzi/portflow
- License: MIT
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)