php-mqtt/client
php-mqtt/client is a PHP MQTT client for connecting to brokers, publishing messages, and subscribing to topics. It supports MQTT 3.1, 3.1.1, and 5.0, QoS levels, TLS, authentication, and configurable settings for IoT and messaging integrations.
Install and verify the environment
composer require php-mqtt/client
php -m | grep -E 'openssl|mbstring' # openssl required for TLS only
The package uses PHP streams (no ext-sockets, no ReactPHP), so it works anywhere PHP runs — including shared hosting and standard php-fpm — but it is synchronous and blocking. That single fact drives most of the design decisions below.
First successful call — publish
use PhpMqtt\Client\MqttClient;
use PhpMqtt\Client\ConnectionSettings;
$settings = (new ConnectionSettings)
->setUsername(config('mqtt.username'))
->setPassword(config('mqtt.password'))
->setConnectTimeout(5)
->setKeepAliveInterval(30);
$client = new MqttClient(config('mqtt.host'), config('mqtt.port'), 'laravel-publisher-'.Str::uuid());
$client->connect($settings, true); // true = clean session
$client->publish('app/orders/created', json_encode($order), qualityOfService: 1);
$client->disconnect();
First successful call — subscribe
$client->connect($settings, true);
$client->subscribe('app/devices/+/telemetry', function (string $topic, string $message, bool $retained) {
Log::info('mqtt', compact('topic', 'message', 'retained'));
}, 1);
$client->loop(true); // blocks; must be called or nothing is received
Where to look first, in order
src/MqttClient.php — the entire public surface: connect, publish, subscribe, unsubscribe, loop, interrupt, disconnect, isConnected.src/ConnectionSettings.php — every knob you will actually tune (TLS, timeouts, keep-alive, LWT, auto-reconnect).src/Contracts/ — Client, MqttClient, and the repository interface; this is what you type-hint and mock in tests.src/Exceptions/ — the exception tree you will catch in supervisors.examples/ in the repo — short, runnable publisher/subscriber scripts; the fastest way to sanity-check a broker.Your first real use case in a Laravel app: publishing from a queued job. MQTT publish is I/O-bound and belongs off the request cycle. dispatch(new PublishMqttMessage($topic, $payload, $qos)) keeps the controller fast and lets the queue handle broker outages via retries.
A MqttClient is a stateful socket with an internal packet-id counter and a subscription repository. Binding it as a singleton in a service provider looks tidy but produces stale connections across queue workers, Octane requests, and reconnects. Bind a factory instead:
// app/Mqtt/MqttClientFactory.php
class MqttClientFactory
{
public function make(?string $clientId = null): Client
{
return new MqttClient(
config('mqtt.host'),
config('mqtt.port'),
$clientId ?? config('mqtt.client_id').'-'.Str::random(8),
MqttClient::MQTT_3_1_1,
Log::channel('mqtt')->getLogger(), // PSR-3
);
}
public function connectionSettings(): ConnectionSettings { /* … */ }
}
Then a thin MqttPublisher service exposes publish(string $topic, string $payload, int $qos = 1, bool $retain = false) and handles connect/disconnect with try/finally. Everything else in the app talks to that service, never to MqttClient directly — which makes swapping in a fake for tests trivial.
The dominant pattern for web requests and jobs. Cheap, stateless, and safe under php-fpm:
public function publish(string $topic, string $payload, int $qos = 1, bool $retain = false): void
{
$client = $this->factory->make();
try {
$client->connect($this->factory->connectionSettings(), true);
$client->publish($topic, $payload, $qos, $retain);
} finally {
if ($client->isConnected()) {
$client->disconnect();
}
}
}
For high-throughput publishing (hundreds of messages per job), connect once and publish in a loop — a TCP+CONNECT handshake per message will dominate your runtime.
Subscriptions only take effect once loop() runs, so a subscriber is a process, not a request. Model it as an Artisan command run under Supervisor or Horizon-style supervision:
class MqttListenCommand extends Command
{
protected $signature = 'mqtt:listen {topic=app/#}';
public function handle(MqttClientFactory $factory): int
{
$client = $factory->make(config('mqtt.client_id').'-listener');
$client->registerLoopEventHandler(function (Client $client, float $elapsed) {
if ($this->shouldStop) { // set from a signal handler
$client->interrupt();
}
});
$client->connect($factory->connectionSettings(), false); // clean session = false
$client->subscribe($this->argument('topic'), [$this, 'onMessage'], 1);
$client->loop(true); // blocks until interrupt()/disconnect()
return self::SUCCESS;
}
public function onMessage(string $topic, string $message, bool $retained): void
{
dispatch(new HandleMqttMessage($topic, $message, $retained)); // keep callback fast
}
}
Two rules make this reliable: never do real work inside the callback (dispatch a job), and always exit through interrupt() so in-flight QoS 1/2 acknowledgements are flushed.
setReconnectAutomatically(true) plus setMaxReconnectAttempts(n) lets the client recover from broker restarts. Crucially, with clean session = false the broker thinks it restored your subscriptions while your PHP process may not — the client's subscription repository replays subscribe() calls after a reconnect, but only for subscriptions registered through the client instance. If you build your own reconnect loop, resubscribe explicitly.
app/{tenant}/{device}/{event}.app/+/+/telemetry (+ = one level, # = rest of tree, must be last).$retained callback parameter. Publish an empty retained payload to delete it.setLastWillTopic, setLastWillMessage, setLastWillQualityOfService, setRetainLastWill) to publish <device>/status = offline when the connection drops ungracefully.loop(true, true, $queueWaitLimit) (1.x signature) exits when the outbound queue drains — handy for "subscribe, grab retained state, exit" scripts. For daemons, use the default infinite loop plus registerLoopEventHandler for periodic work (metrics, health pings, pcntl signal polling). Every loop() iteration also services keep-alive pings; that is the heartbeat keeping the broker from dropping you.
Type-hint PhpMqtt\Client\Contracts\Client in your services so you can swap in a fake or Mockery double. For integration tests, run eclipse-mosquitto in CI (Docker service container) and assert against a real broker — the protocol edge cases (retained flags, QoS downgrades, LWT) are exactly what fakes get wrong.
subscribe() does nothing on its own. It registers the subscription and sends SUBSCRIBE, but incoming PUBLISH packets are only read inside loop(). A script that subscribes and exits receives nothing.
Blocking callbacks cause silent broker disconnects. Keep-alive is serviced between loop iterations. A callback that sleeps 60 s with a 30 s keep-alive interval gets you disconnected. Offload to queues, or bump setKeepAliveInterval() well above your worst-case handler time.
Duplicate client IDs kick each other off. The broker closes the previous connection when a new one presents the same client id. Never hardcode a client id shared by multiple workers — append a UUID or the hostname+PID.
Clean session semantics bite both ways. connect($settings, true) (clean) discards queued messages and subscriptions on disconnect; false requires broker-side persistence and means a restarted daemon inherits a subscription set it may not know about. Combine persistent sessions with a stable client id, or use clean sessions with explicit resubscription — but do not mix.
Callback arity changed across major versions. The 1.x subscribe callback receives (string $topic, string $message, bool $retained, array $matches); newer majors tightened the signature. Pin the major version in composer.json and re-read the callback signature before upgrading — this is a silent ArgumentCountError/ignored-$matches trap.
Wildcard match captures are the underused feature. The $matches array maps + placeholders positionally — perfect for routing app/{tenant}/commands straight into tenant-scoped jobs without string parsing.
TLS configuration is three settings, and only one is optional. setUseTls(true), a correct setTlsCertificateAuthorityFile() (the system CA bundle usually works; bundled distro paths vary), and setTlsSelfSignedAllowed(true) only in development. Also verify the port: 1883 is plaintext, 8883 is TLS — mismatching them yields a confusing handshake timeout rather than a clear error.
Timeouts default too low for flaky links. Tune setConnectTimeout(), socket/read-write timeout, and the resend timeout independently. A connect timeout that is generous while the socket timeout is tight gives you "connected but dead" behaviour that looks like a broker bug.
Exceptions come in flavours — catch the base, then the specifics. MqttClientException is the root; DataTransferException means the socket broke mid-packet (usually safe to retry), protocol violations mean you have a real bug, and repository exceptions indicate subscription bookkeeping problems. Wrap publishes in a retry with backoff; do not blindly retry QoS 2 publishes without idempotency on the consumer side.
disconnect() is worth calling. Skipping it turns every publish into an ungraceful drop, which triggers Last Will messages and spams broker logs. try/finally around your client lifecycle.
Broker packet-size limits are invisible until they aren't. Mosquitto's default message_size_limit is generous, but managed brokers cap it, and MQTT 3.1.1's 256 MB ceiling is theoretical. Chunk large payloads or store them elsewhere and publish a reference — remember the whole payload is buffered in PHP memory.
Passwords are length-limited and usernames are not validated client-side. MQTT 3.1.1 caps credentials at 65535 bytes; more practically, special characters in generated passwords cause auth failures that surface as a plain ConnectionRefused. Log the CONNACK return code — it names the actual reason (bad credentials, not authorised, identifier rejected).
Never share a client across forked processes. Queue workers using pcntl_fork, Octane, or RoadRunner will corrupt the socket state. Connect after the fork, or build the client per job.
Log at debug level first. Passing a PSR-3 logger gives you packet-level visibility (CONNECT/CONNACK/PINGREQ/SUBACK) — the fastest way to diagnose "it connects but never receives". Drop to info once the integration is stable; a busy subscription at debug will flood your logs.
How can I help you explore Laravel packages today?