evilfreelancer/routeros-api-php
A modern PHP client for the MikroTik RouterOS API. Connect to RouterOS 6 and 7 devices, run commands, and parse responses through a clean, framework-agnostic, object-oriented interface. Install via Composer and use it in Laravel or any PHP project.
evilfreelancer/routeros-api-php is a low-level, framework-agnostic client for the MikroTik RouterOS binary API (port 8728, or 8729 over TLS). In Laravel it is not auto-discovered — you wire it up yourself.
Minimal setup:
composer require evilfreelancer/routeros-api-php
Add credentials to config/services.php (do not hardcode them in code):
'routeros' => [
'host' => env('ROUTEROS_HOST', '192.168.88.1'),
'user' => env('ROUTEROS_USER', 'admin'),
'pass' => env('ROUTEROS_PASS', ''),
'port' => (int) env('ROUTEROS_PORT', 8728),
'ssl' => (bool) env('ROUTEROS_SSL', false),
'timeout' => 10,
'attempts' => 2,
],
Bind it as a singleton in AppServiceProvider:
use RouterOS\Client;
$this->app->singleton(Client::class, fn () => new Client(config('services.routeros')));
First use case — list DHCP leases:
use RouterOS\Query;
$rows = app(\RouterOS\Client::class)
->query((new Query('/ip/dhcp-server/lease/print'))->where('status', 'bound'))
->read();
Look first at the RouterOS\Client, RouterOS\Query, and RouterOS\Config classes; nearly everything is query(Query $q)->read().
Wrap the client in a domain service. Do not inject RouterOS\Client into controllers — you will leak query strings everywhere and have no seam for tests.
class RouterOsService
{
public function __construct(private Client $client) {}
public function activeLeases(): array
{
return $this->client
->query((new Query('/ip/dhcp-server/lease/print'))->where('status', 'bound'))
->read();
}
}
Read vs write queries. print/getall support ->where($key, $value); commands must be built as operations:
$q = new Query('/ip/hotspot/user/add');
$q->equal('name', 'guest-01')->equal('password', 'secret')->equal('profile', 'default');
$client->query($q)->read();
Named connections for multiple routers. The package's Config is a plain value object, so build clients via a factory keyed by router name rather than a single singleton:
$client = new Client(config("routeros.routers.{$name}"));
Resolve per-request or cache in a RouterRegistry service. This is the cleanest way to support a fleet without abusing the container.
Multi-command batching with tags. Use ->tag('x') when sending related queries so you can correlate responses — RouterOS does not preserve ordering guarantees across interleaved commands, and the response array contains tag keys.
Queue jobs for anything mutating. Wrap writes in a job with $tries = 1 (or explicit retry policy). RouterOS commands are not idempotent — blind framework-level retries on timeout will duplicate users/queues/rules.
Scheduled commands for polling. A daily/5-min Artisan command that pulls counters into Eloquent is the standard pattern; keep the raw read() result short-lived and map it immediately to your own DTOs, since the package returns arrays with RouterOS-specific keys (.id, and per-command field names).
PSR-3 logging. Pass 'logger' => $psr3Logger in the config array; the client emits connection/query events you can route to a routeros log channel.
Testing. The package has no fakes. Bind a stub Client subclass or a mocked RouterOsService in the container — your service wrapper is the mocking boundary.
sleep or long queue can be silently dead. Catch StreamException/ClientException and reconnect ($client = new Client(...)) rather than retrying on the same instance.timeout (seconds, connection/attempt) and stream-level timeouts have separate semantics, and the 1.7.0 fix specifically corrects PHP 8.4 stream timeout handling (issue #131). If you were pinballing between "connection hangs forever on 8.4" and "premature timeout", upgrade — but still set a small timeout and treat every call as failing.:execute or scripts that call scripts. Before this, those paths could throw or return null. If your code has a defensive if (empty($response)) before 1.7.0, keep it but note it is no longer the failure mode.Query::equal() writes the value into a length-prefixed word — the transport is safe from injection, but you must still validate untrusted input (a user-supplied address can add anything valid in RouterOS syntax). Never interpolate user input into a raw Query path such as /system/script/run arguments..id keys. Every returned record has a '.id' => '*1A' style key. Strip it before persisting or serialising to JSON.read() too. query() returns the client (fluent), not a result. Forgetting ->read() yields a no-op-looking success.api, read, write policies; a 401 manifests as BadCredentialsException, but an under-privileged user surfaces as an empty or error word — log the raw response when debugging./ip/firewall/connection/print and similar can return tens of thousands of rows into a PHP array. Filter server-side with ->where() or ->operations() and paginate with ->equal('count', N) style queries where the menu supports it.RouterOS\Client to add retry/metrics behaviour, implement PSR-3 for logging, and use RouterOS\Config for validation. There are no events or middleware — put your observability in the service wrapper.Upgrade from 1.6.0 is non-breaking (three bug fixes, two first-time contributors); it is worth taking for the PHP 8.4 and Windows fixes alone.
How can I help you explore Laravel packages today?