hamzi/portflow
PortFlow connects Laravel to hardware over serial: thermal printers, scales, barcode scanners, RFID, sensors, and IoT devices. Driver-based architecture, Web Serial support, event routing, Eloquent integration, ESC/POS printing, and buffering.
PortFlow is a bridge, not a library you sprinkle everywhere: it receives raw serial bytes over HTTP from a browser JS shim (or from a backend serial port), turns them into SerialFrame DTOs, and routes those into Laravel events and Eloquent. The fastest way to judge whether it fits your project is to prove the round trip once — a barcode scanner or an ESP32 pushing a JSON line — before you commit any domain code to it.
Minimal install sequence:
composer require hamzi/portflow
php artisan vendor:publish --tag=portflow-config # config/portflow.php
php artisan vendor:publish --tag=portflow-assets # public/vendor/portflow/portflow-serial.js
Add the bridge script to your layout and drop in the Livewire connector:
<script src="{{ asset('vendor/portflow/portflow-serial.js') }}"></script>
<livewire:portflow-connector
:baud-rate="115200"
driver="raw-json"
:auto-connect-on-load="true"
:context="['device' => 'esp32-line-a']"
/>
Where to look first, in this order:
config/portflow.php — default_driver, drivers, driver_options, and mappings. This file is the entire mental model of the package; almost every bug traces back to it.IngestController route (portflow.ingest) — confirm it exists and that php artisan route:list | grep portflow shows it. That endpoint is the only thing your hardware actually talks to.RawJsonDriver, BarcodeLineDriver, EscPosDriver, Rs232Driver) — read the parseInbound / encodeOutbound pair. If you can read those two methods, you understand the driver.First use case to build: a barcode scan that touches the database. Map the inbound payload to an existing domain event and listen for it:
// config/portflow.php
'mappings' => [
[
'driver' => 'raw-json',
'payload_field' => 'type',
'equals' => 'barcode.scan',
'event' => \App\Events\ProductScanned::class,
'event_payload_field' => 'barcode',
],
],
// AppServiceProvider::boot()
Event::listen(ProductScanned::class, \App\Listeners\LookupProduct::class);
That single path — device → POST /portflow/ingest → driver → event → listener → Eloquent — is the whole package. Everything else is configuration of that path.
Scope check before you invest: Web Serial is Chrome 89+ / Edge 89+ only. If your users are on Safari, Firefox, or an embedded kiosk browser, the browser path is dead on arrival and you must use backend direct serial mode instead — which requires a long-running process (php artisan portflow:listen) and a PHP serial extension on the host. Decide this on day one, not in sprint three.
Treat the drivers map as a device registry, not a dumping ground. Name drivers after the device, not after the protocol, so device → driver → mapping stays traceable six months later:
'drivers' => [
'line-scanner' => \Hamzi\PortFlow\Infrastructure\Drivers\BarcodeLineDriver::class,
'scale-dock-1' => \Hamzi\PortFlow\Infrastructure\Drivers\Rs232Driver::class,
'receipt-printer' => \Hamzi\PortFlow\Infrastructure\Drivers\EscPosDriver::class,
'esp32-linestation' => \Hamzi\PortFlow\Infrastructure\Drivers\RawJsonDriver::class,
],
Register custom drivers in AppServiceProvider when they need constructor injection, and in config when they don't — mixing both is where people lose track of why one driver isn't resolving.
PortFlow::registerDriver('modbus', \App\SerialDrivers\ModbusDriver::class);
The drivers differ mainly in delimiters, and picking the wrong one produces the classic "one scan becomes three events" or "nothing ever fires" symptom:
| Device behaviour | Driver |
|---|---|
| Emits a full JSON object per line | raw-json |
Emits a plain ASCII string + \n (keyboard-wedge scanners in serial mode) |
barcode-line |
| Wraps payload in STX/ETX control bytes | rfid-ascii |
| Binary UART packets with length/checksum | fingerprint-packet |
Semicolon records like 12.500;kg;SCALE-A1 |
rs232 |
For raw-json, tune the buffer per device rather than globally. An ESP32 sending 200-byte frames and an RS-485 gateway dumping 4 KB bursts do not want the same ceiling:
'driver_options' => [
'raw-json' => [
'delimiter' => "\n",
'max_bytes' => 16384,
],
],
Note the deliberate design decision: malformed JSON is passed through as {"raw": "..."} rather than dropped. In production this is a feature — you get a SerialFrame you can log or dead-letter instead of a silent hole in your data. In development it's a footgun, because a misconfigured device will happily "work" while producing raw-wrapped garbage.
The synchronous ingest path runs inside an HTTP request that a browser is awaiting. If your listener hits a slow API, does an image resize, or writes to a thermal printer, you will back up the serial bridge. Move routing to the queue for anything that isn't a sub-50ms DB lookup:
// config/portflow.php — route frames through RouteSerialFrameJob
'queue' => [
'enabled' => true,
'name' => 'hardware',
],
Give hardware its own queue and its own worker. Hardware floods are bursty, and you do not want a warehouse scanning 400 items to starve your mail queue.
Two approaches, both returning raw ESC/POS bytes you hand to the browser bridge:
// Template-driven — good for receipts that change with business requirements
$bytes = PortFlow::print('receipts.order', ['order' => $order]);
// Fluent — good for fixed layouts and one-off labels
$bytes = (new EscPosBuilder)
->align('center')->bold()->text('ACME STORE')->bold(false)
->divider()
->align('left')->text('Item 1 ................. $9.99')
->feed(3)
->cut()
->bytes();
Prefer PortFlow::print() with a Blade view for anything a shop owner might want to tweak. The Blade → ESC/POS renderer is the package's highest-leverage feature and the one with the least obvious documentation; budget an hour to experiment with what Blade constructs actually render correctly (tables and CSS will not survive the transformation — it is line-oriented text, not HTML).
For a device the package doesn't cover, scaffold and implement:
php artisan portflow:make-driver MyScale --namespace="App\\Hardware\\Drivers"
The contract is three methods — name(), configure(), parseInbound() / encodeOutbound(). Keep parseInbound pure: no DB, no HTTP, no logging beyond context. Do the work in the event listener. A driver that touches the database is untestable and will be the thing that breaks at 2 a.m.
Also implement SerialEvent on your domain events. The package dispatches non-conforming events anyway but emits a Log::warning — that warning is your only early signal that a context array is being lost between driver and listener.
The HTTP ingest endpoint is the seam. Fake hardware entirely by posting chunks in a feature test:
$this->postJson(route('portflow.ingest'), [
'driver' => 'raw-json',
'chunk' => json_encode(['type' => 'barcode.scan', 'barcode' => '4006381333931'])."\n",
'context' => ['device' => 'esp32-line-a'],
])->assertOk();
Event::assertDispatched(ProductScanned::class);
Build a small set of recorded byte dumps from each real device into tests/Fixtures/serial/ and drive the whole suite from them. This is the single highest-value investment you can make with this package — it lets you develop the entire integration while the hardware is still in shipping.
The browser constraint is architectural, not cosmetic. No Firefox, no Safari, no iOS. Web Serial also requires a secure context — https:// or localhost. A staging environment on http://staging.internal will fail to expose navigator.serial at all, and the error you get is a generic connect failure, not a browser-compatibility message.
User gesture is mandatory for the first connection. navigator.serial.requestPort() must be called from a click handler. auto-connect-on-load only works after permission has already been granted for that origin — it uses navigator.serial.getPorts(). So the first-ever visit always needs the user to press a button; design the UI around that instead of assuming silent reconnect.
Baud rate mismatches produce garbage, not errors. If a device is at 9600 and you configure 115200, you get mojibake chunks that fail JSON parsing and arrive as {"raw": "..."}. When you see a stream of raw-wrapped frames, check the baud rate and the delimiter before you check anything else. Serial fails quietly by nature.
max_bytes silently truncates. The IoTFrameBuffer is a rolling window. If your device emits frames larger than the ceiling, or the delimiter never arrives, the buffer rolls and you lose the start of the frame — the parsed result is a valid-looking frame with corrupted leading data. Set max_bytes to at least 4× your largest expected frame, and verify the delimiter matches the device's actual line ending (\n vs \r\n vs \r is a genuinely common mismatch on legacy scales).
Baud rate precedence in Blade beats localStorage. If :baud-rate is passed explicitly it overrides the remembered value. That's the documented behaviour, but it means a hardcoded :baud-rate="115200" in a shared layout will prevent users from ever adjusting the rate for a different device — pass it conditionally or bind it to a Livewire property.
Mapping rules are single-field equality, nothing more. payload_field + equals cannot express "nested data.type", wildcards, or ranges. The moment your routing needs a second condition, move it out of mappings and into a listener that dispatches further events. Trying to encode logic in config is how config/portflow.php becomes unmaintainable.
Binary protocols over HTTP need thought. Drivers like fingerprint-packet deal in bytes, but the ingest endpoint accepts a chunk in a JSON body. Confirm how binary payloads are transported (base64, escaped string) before wiring a fingerprint module — this is the least-documented corner of the package and the one most likely to cost you a day.
CSRF on the ingest route. The JS bridge lets you pass csrfToken, and the Livewire component reads it from the csrf-token meta tag. If you're building a custom integration or posting from a kiosk, remember the token expires with the session — long-lived kiosk pages need a token refresh strategy or a route excluded from CSRF with its own authentication.
Livewire 3 is a hard dependency even if you never render a Livewire component. The connector and status components are Livewire classes. If your app is on Livewire 2 or doesn't use Livewire at all, you're pulling it in regardless.
Filter by vendor/product ID where you can. :filters="[['usbVendorId' => 6790, 'usbProductId' => 29987]]" prevents a user from selecting the wrong port on a kiosk with a printer, a scanner, and a scale all attached. Without it, the browser port picker is a coin flip for whoever is standing at the counter.
Wrap the facade. PortFlow::encode(), PortFlow::print(), PortFlow::registerDriver() are called from your domain code, and this package has 2 stars, 0 dependents, and a single maintainer. Put a thin HardwareGateway interface in your app with PortFlow as the implementation, and keep PortFlow out of your controllers and listeners. If the package stalls or a device doesn't fit, you swap one adapter rather than grepping 40 files.
Extension points worth knowing: EscPosBuilder (add custom byte sequences for printer features the package doesn't expose — cash drawer kick, QR codes), IoTFrameBuffer (wrap it in your own driver for non-newline framing such as fixed-length or length-prefixed protocols), and SerialDriver (the whole abstraction; a well-written driver is portable to another bridge if you ever leave).
Operational checklist for go-live: pin the exact version in composer.json (the last release is dated in the future in the README metadata, so treat the version as your only anchor), commit config/portflow.php with driver options tuned per device rather than left at defaults, run a dedicated queue worker for the hardware queue under a supervisor, log every raw-wrapped frame to a separate channel, and write down which physical port maps to which driver — because the person debugging it at 6 a.m. will not be the person who set it up.
How can I help you explore Laravel packages today?