Skip to content
sanchescomPublic

About

Cross-platform PHP library for Wi-Fi: scan, join, known networks, and on Linux a hotspot and a watchdog that keeps a headless device (Raspberry Pi) online. Verified on real hardware.

Topics

Resources

Contributing

Stars

52 stars

Watchers

3 watching

Forks

Repository files navigation

CI Latest Version PHP Version License

PHP WiFi

A cross-platform PHP library for scanning and joining Wi-Fi networks. One object facade (WiFi) drives nmcli or wpa_cli on Linux, networksetup on macOS and netsh on Windows through a shared Backend interface, returns immutable value objects instead of arrays, and ships a CLI (bin/wifi) on top of the same API. Linux has two backends, picked automatically — see "Linux — two backends" below.

On Linux it also raises a hotspot (hostapd/dnsmasq or NetworkManager) and ships wifi watch, a watchdog that keeps a headless device — a Raspberry Pi with no keyboard or screen — reachable: it rejoins the network on its own, and raises a provisioning hotspot when it cannot, leaving it up while someone is attached to it. wifi provision puts such a device on a network from a phone: a setup hotspot with a captive-portal page, and a failed join that says why. Every release is verified on real hardware before it is tagged — the transcripts, and the defects those runs found, are in docs/verified-on.md.

wifi list on a Raspberry Pi

wifi device and wifi list --unique on a Raspberry Pi, output captured live

Run over SSH on the maintainer's Pi, unprivileged (femus, member of netdev), NetworkManager 1.52.1 — full transcript in docs/verified-on.md:

$ php bin/wifi device
wlan0
[exit 0]

$ php bin/wifi list
1 network(s) broadcast no SSID (hidden); they are shown as "-".
 SSID             BSSID              Channel  Band  Quality  dBm  Frequency  Connected  Security
-------------------------------------------------------------------------------------------------
 BELL340          0e:ac:8a:99:58:5c  1        2.4   87%      -56  2412       false      WPA2
 VTECH_5764_9764  a6:97:5c:b7:97:64  1        2.4   70%      -65  2412       false      WPA2
 BELL340          0e:ac:8a:99:58:5d  157      5     64%      -68  5785       false      WPA2
 -                0e:ac:8a:99:58:5e  157      5     64%      -68  5785       false      WPA2
[exit 0]

The fourth row is a real hidden access point — an empty SSID broadcast from 0e:ac:8a:99:58:5e, shown as -. (The stderr line above is this release's wording; docs/verified-on.md records the exact run that found and fixed the earlier, OS-wrong version of it.)

Requirements

  • PHP 8.2, 8.3, 8.4 or 8.5
  • illuminate/collections 11, 12 or 13 (pulled in automatically; the constraint lets the package coexist with Laravel 11–13 applications)
  • Linux, macOS (Darwin) or Windows, with the matching system tool: nmcli (NetworkManager) or wpa_supplicant/wpa_cli on Linux (see "Linux — two backends" below), networksetup (built in) on macOS, netsh (built in) on Windows
  • Optional, for wifi provision: qrencode (a QR code for joining the setup network) and a running avahi-daemon (the device's <hostname>.local name)

Installation

composer require sanchescom/php-wifi

Quick start

Scan and read the strongest network

use Sanchescom\WiFi\WiFi;

$wifi = WiFi::create();
$strongest = $wifi->scan()->strongest();

printf("%s: %.0f dBm, %s\n", $strongest->ssid, $strongest->signal->dbm, $strongest->security->value);

Connect

use Sanchescom\WiFi\Value\Credentials;
use Sanchescom\WiFi\WiFi;

$wifi = WiFi::create();
$network = $wifi->scan()->bySsid('My-WiFi-Network');
$wifi->connect($network, Credentials::password('secret123'));

The wireless device is detected automatically; pass a third Device argument to override it.

Why a join failed — Linux only

Since 3.3.0 both Linux backends name the reason, where the tool underneath gives one away:

use Sanchescom\WiFi\Exception\NetworkNotFound;
use Sanchescom\WiFi\Exception\NoAddress;
use Sanchescom\WiFi\Exception\WrongPassphrase;

try {
    $wifi->connect('Home', Credentials::password($typed));
} catch (WrongPassphrase) {
    // the network is there and refused the passphrase
} catch (NetworkNotFound) {
    // no such network in range
} catch (NoAddress) {
    // joined, but no DHCP lease
}

WrongPassphrase and NoAddress extend CommandFailed, so code that catches that, or WiFiException, keeps working. Neither tool reports a wrong passphrase in so many words: NetworkManager asks for the secret a second time until nmcli times out, and wpa_supplicant disables the network block for ten seconds after the failed handshake. The backends read those signs — on WpaCliBackend that also ends the wait after about nine seconds instead of twenty-one.

List known (saved) networks — Linux only

use Sanchescom\WiFi\WiFi;

$wifi = WiFi::create();
foreach ($wifi->knownNetworks() as $known) {
    printf("%s (%s)\n", $known->name, $known->active ? 'active' : 'saved');
}

On NmcliBackend, $known->name is the NetworkManager connection name; $known->ssid is the real SSID, resolved with one extra nmcli -g 802-11-wireless.ssid connection show <name> call per profile — knownNetworks() costs N+1 nmcli commands for N wireless profiles, not one. The two differ for a saved hotspot profile: a connection named Hotspot reports its actual SSID in $ssid. A failed per-profile lookup falls back to the connection name rather than failing the whole list.

On WpaCliBackend, wpa_supplicant has no notion of a connection name distinct from the SSID, so $known->name always equals $known->ssid, and $known->device is always null (wpa_supplicant's list_networks never reports which interface owns a configured network). $known->active reflects list_networks' [CURRENT] flag, which means the network is selected — the one wpa_supplicant is trying or last used — not necessarily associated right now; a network can show active: true while the radio is actually disconnected from it.

Start a hotspot — Linux only

use Sanchescom\WiFi\Value\HotspotConfig;
use Sanchescom\WiFi\WiFi;

$wifi = WiFi::create();
$hotspot = $wifi->startHotspot(new HotspotConfig(ssid: 'femus-setup', password: 'change-me-now'));
printf("%s on %s\n", $hotspot->ssid, $hotspot->device);

HotspotConfig also takes a channel and a country: new HotspotConfig(ssid: …, password: …, channel: 11, country: 'DE'). Without a channel WpaCliBackend's hotspot is on 6 (2.4 GHz) or 36 (5 GHz) and NetworkManager picks its own. Without a country hostapd leaves the radio in the regulatory domain the system has — on a Raspberry Pi that was never told its country, the world domain. NmcliBackend passes the channel on and refuses a country with UnsupportedOperation: NetworkManager takes the regulatory domain from the system.

Calling a capability the active backend does not implement (known or hotspot on macOS/Windows) throws UnsupportedOperation; check first with $wifi->supports(SupportsHotspot::class).

Provisioning a headless device: wifi provision

Since 3.3.0 one command turns a headless Linux device into one that is set up from a phone — no SSH, no keyboard, no monitor:

$ sudo wifi provision
Setup network: <hostname>-setup
Passphrase:    <twelve random characters>
Setup page:    http://10.42.0.1/

It scans, raises a setup hotspot, and serves a page on it. The hotspot is a captive portal: its DNS answers every name with the device, and the page redirects every request meant for another host to itself, which is what makes a phone that has just joined open it. With qrencode installed, a QR code for joining the setup network is printed as well.

The page lists the networks and takes a passphrase. A single radio cannot stay an access point and join a network, so the phone loses the page the moment the join starts. The page therefore answers first — what is about to happen, and where the device will be — and joins once that answer has been sent. If the join fails, the hotspot comes back and the page says why: The passphrase for "Home" was not accepted. If it works, the command prints Joined Home. and exits 0; with avahi-daemon running it also names <hostname>.local. After --timeout seconds without a join it takes the hotspot down and exits 1.

It needs root: the page is served on port 80, and on both backends the captive portal's DNS is root's to configure. On a machine without NetworkManager, give the unit that runs it KillMode=process — the DHCP client the join starts has to outlive the command. To keep the device on its network afterwards, run wifi watch.

The page has no login. It is served only on the setup hotspot's own address, only while that hotspot is up, to whoever knows its passphrase.

The same building blocks in your own application

Provision\Portal is what the page is built from, with no framework underneath:

use Sanchescom\WiFi\Provision\Portal;
use Sanchescom\WiFi\WiFi;

$portal = new Portal(WiFi::create());

$portal->networks();               // [['ssid' => 'Home', 'band' => '5', 'quality' => 87, 'security' => 'WPA2'], …]
$portal->connect('Home', $typed);  // ['ok' => false, 'ssid' => 'Home', 'reason' => 'wrong_passphrase', 'message' => '…']
$portal->status();                 // ['hotspot' => false, 'joining' => false, 'connected' => 'Home', 'lastAttempt' => …]

connect() never throws; reason is wrong_passphrase, network_not_found, no_address, permission_denied, invalid or failed. The same three are JSON endpoints — GET /api/networks, GET /api/status, POST /api/connect with ssid and password:

$response = $portal->handle($method, $path, $_POST);   // null when the path is not one of the three

if ($response !== null) {
    $response->send();
}

A Response is plain data (status, headers, body), so a framework can turn it into its own. Put these behind your application's authentication: connect() changes which network the device is on.

The original demo: examples/provision/

examples/provision/ is the hand-assembled version wifi provision grew out of: a shell supervisor, a single PHP page and a systemd unit. It still works and shows every moving part. See examples/provision/README.md.

Provisioning page on a phone

This was taken live on the maintainer's Pi during the 3.0 release, a phone joined the femus-setup hotspot and opened http://10.42.0.1:8080/. At the time the list showed only femus-setup · 2.4 GHz · 0%, because while nmcli is running the hotspot on the Pi's one radio, nmcli device wifi list can see the hotspot itself and nothing else — 3.1 fixes this with a cached scan (below).

A single Wi-Fi radio can either scan or run an access point, never both, so hotspot.sh scans first (wifi list --unique --json) and caches the result to PROVISION_CACHE before starting the hotspot. index.php reads that cache on every GET and labels the list "Scanned before the hotspot started."; a missing or unreadable cache falls back to a live scan.

Pressing Connect stops the hotspot before joining: while the radio is running the access point, NetworkManager's own scan list is empty, so it refuses a join outright no matter what the cached list shows. The phone loses the page at that point — expected and unavoidable with one radio — and index.php waits a moment for the radio to leave AP mode, then joins through WiFi::connect(), which scans again to resolve the SSID (turning a typo into NetworkNotFound instead of an opaque nmcli exit code).

Once a connection succeeds, index.php writes an empty marker file at PROVISION_DONE and the run ends with the hotspot down. hotspot.sh polls for that marker once a second (every RESTART_BACKOFF seconds while it is restarting a dropped hotspot), for at most PROVISION_TIMEOUT seconds (also giving up if the web server dies), then stops the built-in PHP server, runs wifi hotspot stop (a no-op by then), and exits — the unit ends itself once provisioning is done instead of running indefinitely. If the join fails instead, the hotspot is left down; hotspot.sh's wait loop notices within a few seconds and brings it back so the phone can rejoin and the person can retry.

A mistyped passphrase drops the access point without ever delivering an HTTP response, so index.php also writes the outcome of every submitted attempt to PROVISION_STATE — {"at": <unix time>, "ssid": …, "ok": false, "error": "…"} — and shows "Last attempt: … failed — <reason>" the next time the page loads. This is the only way a person learns why their attempt failed once the page itself is unreachable; the record is ignored (and the page falls back to no banner) once it is more than an hour old, or timestamped in the future. The passphrase is never part of it.

Variable Default Meaning
PROVISION_CACHE /run/php-wifi-provision/networks.json Where the pre-hotspot scan is cached as JSON.
PROVISION_DONE /run/php-wifi-provision/done Marker file created once a connection succeeds.
PROVISION_STATE /run/php-wifi-provision/last-attempt.json Where the outcome of the last connection attempt is recorded as JSON.
PROVISION_TIMEOUT 900 Seconds hotspot.sh waits for PROVISION_DONE before giving up.
RESTART_BACKOFF 5 Seconds to wait between attempts to bring a dropped hotspot back.
MAX_RESTARTS 5 Consecutive failed hotspot restarts before hotspot.sh gives up (still exiting 0).

See examples/provision/README.md for the full install steps and the provision.service unit that provisions /run/php-wifi-provision for these two files.

Design

Pure parsers over real fixtures. Every src/Parser/** class is a pure function: tool output in, a list<Network> (or a Device, or a list of KnownNetwork) out. No shell calls, no state. Every parser is tested against real nmcli --terse, system_profiler and netsh output captured from actual machines, not hand-written approximations of it.

Command owns escaping, and nowhere else does. src/Shell/Command.php is the only place a program, its arguments and its environment are rendered into a shell string — escapeshellarg() on Linux and macOS, cmd-safe quoting on Windows. Backends never build a shell string themselves; they build a Command and hand it to a CommandRunner.

null over sentinels. 2.x reported an unreadable signal as -100 dBm / 0% and an unrecognised channel as frequency === 0 — both indistinguishable from a real (if extreme) reading. 3.0 reports null: $network->signal, $network->channel, $network->band and $network->frequency are all nullable, and nothing else is overloaded to mean "unknown".

Capability interfaces, not silent no-ops. Known-networks and hotspot support are declared as SupportsKnownNetworks and SupportsHotspot interfaces, implemented by both Linux backends (NmcliBackend and, since 3.2, WpaCliBackend) and neither macOS nor Windows backend. Calling knownNetworks() or startHotspot() against a Backend that does not implement the relevant interface throws UnsupportedOperation immediately, instead of returning an empty list or silently doing nothing.

Linux — two backends

Linux has two backends behind the one WiFi facade:

  • NmcliBackend drives NetworkManager's nmcli. Full parity, and the only Linux backend before 3.2.
  • WpaCliBackend drives wpa_supplicant directly through wpa_cli, for machines with no NetworkManager at all — minimal Raspberry Pi OS Lite images and most embedded distributions run wpa_supplicant directly and have no nmcli to drive. It has the same parity: scan, connect (including asking a DHCP client for an address once associated), disconnect, known networks, forget, and a hotspot — through hostapd + dnsmasq instead of nmcli's built-in one.

WiFi::create() picks between them automatically, through BackendFactory::forLinux(): it runs one cheap probe, nmcli -t -f RUNNING general (with LANG=C), and returns NmcliBackend when it exits 0 and prints running, WpaCliBackend otherwise — a missing nmcli binary, a stopped NetworkManager, or any other failure. The probe never throws: a failed probe is itself the answer ("no usable NetworkManager here"), not an error. BackendFactory::forOs(Os::Linux, …) is unchanged and always returns NmcliBackend, so code and tests that build a backend directly for a fixed OS are unaffected; only forCurrentOs() (and therefore WiFi::create()) routes Linux through the probe.

use Sanchescom\WiFi\Backend\BackendFactory;
use Sanchescom\WiFi\Shell\ShellCommandRunner;

$backend = BackendFactory::forLinux(ShellCommandRunner::forCurrentOs());
// NmcliBackend when NetworkManager is running, WpaCliBackend otherwise.

What WpaCliBackend needs installed

Tool Used for Required?
wpa_supplicant / wpa_cli Everything — scan, connect, disconnect, known networks, forget Always
iw Device detection (iw dev); wifi watch's station count (iw dev <iface> station dump) Always
A DHCP client — dhcpcd, udhcpc, or dhclient Getting an address after connect() associates Recommended — connect() still succeeds without one, but the interface is then associated with no address; something else (a static configuration, systemd-networkd) has to address it
hostapd The hotspot access point Only for startHotspot() / stopHotspot() / isHotspotActive()
dnsmasq DHCP for hotspot clients, and the captive portal's DNS Only for the hotspot
qrencode The QR code wifi provision prints (either backend) Optional
avahi-daemon <hostname>.local, which wifi provision names after a join (either backend) Optional

Every tool above is spawned by its absolute path, resolved with which against a fixed search path that includes /usr/local/sbin, /usr/sbin and /sbin (Backend\Linux\ToolPath) — directories absent from an unprivileged user's PATH on Debian and Raspberry Pi OS, where proc_open() resolves a bare program name using PHP's own PATH, never the Command's own $env. This was found live on the Pi: proc_open(["wpa_cli", "-v"], …) failed while ["/sbin/wpa_cli", "-v"] exited 0 — see docs/verified-on.md.

Privileges — WpaCliBackend

docs/verified-on.md's 3.2.0 run drove every WpaCliBackend command — list, connect, disconnect, forget, hotspot start/status/stop — under sudo; that is the only privilege level this release measured. PermissionDenied::fromWpaCli()'s own hint names an alternative that was not exercised this release: a wpa_supplicant whose ctrl_interface config line grants a GROUP (e.g. netdev) lets a member of that group talk to the control socket without root. Raising the hotspot needs root regardless of that setting — startHotspot() flushes and assigns the interface's IP address (ip addr) and binds hostapd and dnsmasq to it, all privileged operations — and so do most DHCP clients when asked to configure an interface. Since 3.2.5 wifi hotspot status needs root on this backend too: the pid files it reads are in /run/php-wifi, which only root can enter, and an unprivileged call answers inactive.

Two behaviour changes worth knowing about

  • connect() with a passphrase now replaces an existing NetworkManager profile for that SSID (NmcliBackend). Measured on the Pi: nmcli --ask only prompts for a secret when it has none of its own already, so a saved profile holding a stale passphrase made nmcli connect with that stored secret and silently ignore a freshly typed, corrected one. connect() now runs nmcli connection delete <ssid> first when a passphrase is given, so the new passphrase actually wins — at the cost of any other setting that profile held, such as a static address or autoconnect=no.
  • KnownNetwork::$active means something different on the two Linux backends. On NmcliBackend it reflects nmcli's own idea of the active connection. On WpaCliBackend it is wpa_supplicant's [CURRENT] flag from list_networks, which means the network is selected — the one wpa_supplicant is trying or already used — not necessarily associated right now. A network can show active: true while the radio is actually disconnected from it.

Platform matrix

Capability Linux (nmcli) Linux (wpa_cli) macOS (networksetup) Windows (netsh)
scan ✅ verified live (NetworkManager 1.52.1) ✅ verified live (NetworkManager stopped, wpa_supplicant driving the radio directly) ✅ verified live — SSIDs come back redacted unless the process running PHP has Location Services; BSSIDs are never reported for other networks ✅ tests only — no Windows machine
connect ✅ verified live ✅ verified live, including DHCP (dhcpcd) ✅ tests only — not run against real hardware in 3.0 ✅ tests only
disconnect ✅ verified live ✅ verified live ✅ tests only — not run against real hardware in 3.0 ✅ tests only
detect (device()) ✅ verified live ✅ verified live (implicit — no --device was passed in any 3.2.0 run, so iw dev resolved it every time) ✅ verified live ✅ tests only
known networks ✅ verified live forget() ✅ verified live; knownNetworks() (wifi known) — tests only, not shown in the 3.2.0 run ❌ throws UnsupportedOperation ❌ throws UnsupportedOperation
hotspot ✅ verified live ✅ verified live (hostapd + dnsmasq) ❌ throws UnsupportedOperation ❌ throws UnsupportedOperation
hotspot channel / country channel ✅ verified live; a country is refused (UnsupportedOperation) ✅ both verified live ❌ ❌
why a join failed (WrongPassphrase, NetworkNotFound, NoAddress) wrong passphrase and missing network ✅ verified live; NoAddress tests only wrong passphrase ✅ verified live; NoAddress tests only ❌ plain CommandFailed ❌ plain CommandFailed
wifi provision / Provision\Portal ✅ verified live with an iPhone — the page opens by itself; Android not run ✅ verified live with an iPhone; Android not run ❌ provision exits 2; nothing in Portal needs a hotspot, but it was not run there ❌ same as macOS

See docs/verified-on.md for the raw commands behind every "verified live" cell above. wifi watch (Linux only, either backend) was verified live in three of its five states — see the CLI reference below.

The Windows backend last ran on a real machine in 2019, against the 0.x series, where scan and connect worked. Nothing in the 3.x line has been run on Windows; NetshBackend and ProfileFile are covered by fixtures only.

On Windows, a 6 GHz network is only reported as Band::GHz6 when netsh prints a Band : field for it (Windows 11 22H2 and later); on older builds it falls back to a channel-derived guess.

Linux — Privileges (polkit)

nmcli device wifi connect asks NetworkManager to create and activate a connection. A user logged in at the console is allowed by default; a PHP process running as www-data (PHP-FPM, a queue worker, cron) is not — it fails with Not authorized to control networking or Insufficient privileges. Grant it the three NetworkManager actions this library uses:

// /etc/polkit-1/rules.d/50-php-wifi.rules
polkit.addRule(function (action, subject) {
    if (subject.user === "www-data" &&
        (action.id === "org.freedesktop.NetworkManager.network-control" ||
         action.id === "org.freedesktop.NetworkManager.wifi.scan" ||
         action.id === "org.freedesktop.NetworkManager.settings.modify.system")) {
        return polkit.Result.YES;
    }
});

On systems whose polkit still uses localauthority (Debian 11 and older):

# /etc/polkit-1/localauthority/50-local.d/php-wifi.pkla
[php-wifi]
Identity=unix-user:www-data
Action=org.freedesktop.NetworkManager.network-control;org.freedesktop.NetworkManager.wifi.scan;org.freedesktop.NetworkManager.settings.modify.system
ResultAny=yes
ResultInactive=yes
ResultActive=yes

Note

On Debian and Raspberry Pi OS, adding the user to the netdev group (adduser www-data netdev) grants only settings.modify.system; the connect call still needs network-control, so use one of the rules above.

Live finding (Raspberry Pi, Debian 13, 2026-09-09/10): netdev membership alone lets an unprivileged process scan (org.freedesktop.NetworkManager.wifi.scan) and add a connection profile (settings.modify.system), but not activate one (network-control). Concretely: wifi hotspot start and wifi connect called without network-control are refused with PermissionDenied — but the connection profile NetworkManager creates on the way to activation is not rolled back, so a refused call still leaves a profile behind. Running the same command once unprivileged (refused) and once with sudo (succeeds) therefore creates two profiles with the same name; wifi forget <name> removes one profile per call, so a duplicate needs two calls.

Security

Every command a backend runs is built as a Command value object (src/Shell/Command.php) and rendered by exactly one code path: no backend ever concatenates a shell string itself. On Linux and macOS every argument goes through escapeshellarg(); on Windows, ", %, ! and raw \n/\r — the five characters that can break out of a cmd.exe double-quoted argument — are replaced with a space. Arguments marked as secrets (passwords) are never shown in an exception message, a test assertion helper, or toDisplay() — they print as ***. On Windows, the profile handed to netsh is written to a random file under the system temp directory (Backend\Windows\ProfileFile) with the SSID and passphrase XML-escaped, and the file is deleted as soon as netsh has read it, including on failure. CommandFailed::$command keeps the original Command with the unmasked argument list — getMessage() is masked, but do not dump the exception object into logs or error pages.

Since 3.2, ShellCommandRunner execs with an argv array (proc_open($command->toArgv(), …)) instead of a rendered shell string, so no /bin/sh -c (or, on Windows, a bare cmd wrapper) sits between this library and the tool it runs — one fewer process, and one fewer place a secret could show up in ps. The one deliberate exception is NetshBackend::scan()'s cmd /c "chcp 65001 >nul & netsh …" composite, which keeps working unchanged: cmd is the program there, not a shell wrapper around one. A command can also carry input for the child's stdin (Command::$stdin, masked in toDisplay() as <<< '***' when $stdinIsSecret is set) — the mechanism both Linux backends use below.

Where a passphrase reaches a process's own arguments, precisely:

  • Neither Linux backend puts a connect() passphrase in any process's arguments. NmcliBackend::connect() passes it to nmcli --ask on stdin; WpaCliBackend::connect() passes wpa_cli's interactive stdin the key derived from it (since 3.3.0 — before that, the passphrase itself). Measured on the Pi during a live connect, sampling ps -ww -eo args for the passphrase (read from a file, so the measuring grep itself never carries the secret): 0 matches, on both backends — see docs/verified-on.md. 3.1 could only make this claim for the wifi process's own argv; nmcli itself still carried the secret for the duration of that call. 3.2 closes that window on both Linux backends.
  • NmcliBackend::startHotspot() no longer passes the hotspot passphrase as an argument, since 3.3.0. nmcli device wifi hotspot … password <secret> has no stdin mode, so the hotspot is raised in two steps: nmcli connection add creates the access-point profile without a passphrase, and nmcli --ask connection up activates it and reads the passphrase from stdin. No file is written and no root is needed beyond what the old command needed; NetworkManager stores the passphrase in the profile, as before.
  • WpaCliBackend::startHotspot()'s passphrase never reaches any process's arguments. It is written to a hostapd config file (Backend\Linux\HostapdConfig, created via tempnam() at mode 0600, since 3.2.5 inside /run/php-wifi) and deleted once hostapd has read it. Measured on the Pi during a live hotspot: 0 matches in the same ps sample.
  • macOS's NetworksetupBackend::connect() passes the passphrase as a plain networksetup argument, unchanged and untouched by this release (networksetup -setairportnetwork <device> <ssid> <password>).

Runtime files, since 3.2.5. WpaCliBackend's hostapd and dnsmasq pid files and the watchdog's state file are written by root at fixed names. They used to be in the system temp directory, where any local user could plant a symlink under one of those names. They are now in /run/php-wifi (Backend\Linux\RuntimeDirectory), created at mode 0700; a directory that is a symlink, belongs to another user, or is writable by group or others is not written to, read from or deleted from. WIFI_RUNTIME_DIR names another directory, and must then be set for every process that has to find the same files. A process that is not root uses a directory of its own under the temp directory, with the same checks.

Verified on

docs/verified-on.md is the release gate for every tag from 3.0.0 on: the raw output of device, list, list --unique, known, hotspot start/status/stop, forget, watch --once, and a real connect to the maintainer's own network, run on a Raspberry Pi — on NmcliBackend since 3.0.0, and on WpaCliBackend as well since 3.2.0. For 3.3.0 it also holds wifi provision on both backends, driven with curl and then with a phone, and the replies nmcli and wpa_supplicant give to a wrong passphrase, which the typed failures are read from. Each release's section ends with what was not verified.

CLI reference

Installed via Composer, the binary is vendor/bin/wifi; from a checkout of this repository it is php bin/wifi.

Command Options Description
list --unique, --connected, --json Show surrounding Wi-Fi networks
connect --ssid=, --bssid=, --password=, --password-file=, --device= Connect to a network — one of --ssid/--bssid is required
disconnect --device= Disconnect from the current network
device — Show the detected Wi-Fi device
known — List known (saved) networks
forget <ssid-or-name> — Forget a known network; prints Forgot <name>.
hotspot start --ssid=, --password=, --password-file=, --band=, --channel=, --country=, --device= Start a hotspot (--band is 2.4 or 5; --country is a two-letter code and works on WpaCliBackend only)
hotspot stop — Stop the hotspot
hotspot status — Print active or inactive
provision --ssid=, --password-file=, --port=, --timeout=, --channel=, --country=, --device= Raise a setup hotspot with a captive-portal page a phone uses to put the device on a network; exits 0 once it has joined, 1 after --timeout (default 900) seconds. Without --ssid the hotspot is <hostname>-setup; without --password-file a random passphrase is generated and printed
watch --ssid=, --interval=, --retry=, --hotspot-ssid=, --hotspot-password-file=, --device=, --once Keep rejoining a network, raising a provisioning hotspot when it cannot (requires SupportsHotspot, i.e. either Linux backend)

--device is optional everywhere it appears; the wireless device is detected automatically when omitted.

An SSID or connection name that starts with - (e.g. -my-network) looks like an option to the CLI's own parser and must be passed after a literal --: wifi forget -- -my-network.

Exit codes: 0 success · 1 general error (bad usage, a command the underlying tool rejected) · 2 UnsupportedOperation — the active backend does not implement the capability (e.g. known/hotspot on macOS or Windows) · 3 PermissionDenied.

--password-file reads the password from a file instead of a plain argv value, keeping the passphrase off the wifi command line itself. On Linux it is still passed to nmcli as an argument one process later, so a local user watching ps during the call can still see it; on Windows it never reaches a command line at all, since it goes through the temp connection-profile file instead. --password-file=- reads it from stdin instead:

printf '%s' "$PASSWORD" | wifi connect --ssid=home --password-file=-

Exactly one trailing newline is stripped and nothing else, so a passphrase may legitimately end in a space. --password and --password-file are mutually exclusive; an empty file, an empty value, a directory path, or a tty on stdin are each rejected with a clear message and exit 1.

--json prints one JSON object per network instead of the table; --unique/--connected still apply first, and the hidden-SSID hint stays on STDERR so stdout is valid JSON. Real output, trimmed to two networks, from WIFI_FAKE_RUNNER=tests/Fixtures/cli/linux WIFI_FAKE_OS=Linux php bin/wifi list --unique --json:

[
    {
        "ssid": "BELL340",
        "hidden": false,
        "bssid": "02:00:00:00:00:01",
        "channel": 1,
        "band": "2.4",
        "frequency": 2412,
        "quality": 100,
        "dbm": -50,
        "security": "WPA2",
        "securityFlags": "(none) pair_ccmp group_ccmp psk",
        "connected": false
    },
    {
        "ssid": "",
        "hidden": true,
        "bssid": "02:00:00:00:00:05",
        "channel": 157,
        "band": "5",
        "frequency": 5785,
        "quality": 67,
        "dbm": -67,
        "security": "WPA2",
        "securityFlags": "(none) pair_ccmp group_ccmp psk",
        "connected": false
    }
]

bssid, channel, band, frequency, quality and dbm are null when the platform does not report them.

wifi provision

$ sudo wifi provision --ssid=femus-setup --password-file=/tmp/hs.pass
Setup network: femus-setup
Passphrase:    ********
Setup page:    http://10.42.0.1/
█▀▀▀▀▀█ …                              (a QR code, when qrencode is installed)
2026-10-06T19:12:48+00:00 provision: serving
2026-10-06T19:14:21+00:00 provision: joining
2026-10-06T19:14:26+00:00 provision: restarted
2026-10-06T19:17:23+00:00 provision: joining
2026-10-06T19:17:31+00:00 provision: done
Joined BELL340.
The device answers to femus-pi.local on that network.

The first four lines and the last two go to stdout, the timestamped ones to stderr — under systemd, all of it to the journal. The run above is the one recorded with a phone on the Pi (the passphrase masked here): a wrong passphrase first (joining, then restarted — the hotspot is back), then the right one.

Option Default
--ssid= <hostname>-setup Name of the setup hotspot
--password-file= a random 12-character passphrase, printed File holding the hotspot passphrase, or - for stdin. There is no inline option: the passphrase would sit in ps
--port= 80 Port of the setup page. Phones only open a captive portal on 80 by themselves
--timeout= 900 Seconds to wait for a join before taking the hotspot down and exiting 1
--channel= 6 on WpaCliBackend; NetworkManager's own choice on NmcliBackend Channel of the setup hotspot
--country= — Regulatory domain, two letters; WpaCliBackend only
--device= auto-detected Which radio to use

What each state on stderr means:

State
serving The hotspot is up and the page is waiting
joining The page has taken the hotspot down and is joining the network that was picked
restarted The join failed; the hotspot was raised again and the page now shows the reason
done The device is on the network; the command exits 0

It needs root. It scans once, before the hotspot goes up — a single radio cannot scan while it is an access point — so the list on the page is the one from that moment. The page is served on the hotspot's own address only, and only while the hotspot is up.

A unit that runs it at boot, on either backend:

# /etc/systemd/system/wifi-provision.service
[Unit]
Description=php-wifi: put this device on Wi-Fi from a phone
After=network.target

[Service]
ExecStart=/opt/php-wifi/bin/wifi provision --ssid=my-device-setup --password-file=/etc/php-wifi-setup.passphrase
# Without NetworkManager the DHCP client the join starts has to outlive this unit.
KillMode=process

[Install]
WantedBy=multi-user.target

As written it runs on every boot, also on a device that is already on its network. To run it only when needed, start it from whatever decides that — a button, or a check that no saved network could be joined.

wifi watch

wifi watch on a Raspberry Pi: hotspot_raised, hotspot_busy, then recovered once --retry passes; log recorded live

Keeps a headless device reachable. On every tick it checks whether the device is already connected; if not, it tries to rejoin the target network (or every known network, in order, when --ssid is omitted); if that fails too, it raises a provisioning hotspot. While the hotspot is up, it counts attached stations (iw dev <iface> station dump) and leaves it alone as long as someone is attached — a person typing a passphrase must never be dropped mid-session, a lesson from 3.1's live run. Once nobody is attached and --retry seconds have passed since the hotspot went up, it tears the hotspot down and tries the real network again. If the station count cannot be read at all (iw missing, or station dump failing), the hotspot is treated as occupied and left up — so install iw. A systemd unit is in examples/watch/.

Option Default Meaning
--ssid= any known network The SSID to keep rejoining; omit to try every known network in order
--interval= 30 Seconds between ticks (only used by the looping form, not --once)
--retry= 300 Seconds an idle, unattended hotspot is left up before the next retry
--hotspot-ssid= — (required) SSID of the hotspot to raise
--hotspot-password-file= — (required) Read the hotspot passphrase from a file, or from stdin with -; omitting it fails with "passphrase must be 8…" — HotspotConfig requires one, so watch cannot raise an open hotspot
--device= auto-detected Pins the interface both reconnection attempts and station counts use
--once off Run a single tick, print the resulting state to stdout, and exit, instead of looping forever — how the release gate checks each state on the Pi

watch requires the active backend to implement SupportsHotspot (either Linux backend); on macOS or Windows it exits 2 like any other unsupported operation.

Each tick resolves to one of five states. --once prints it to stdout; the looping form logs a timestamped line to STDERR on every state change (connected is otherwise logged only the first time it is reached, not on every uneventful tick that follows):

State Meaning
connected Already joined to a client network; nothing was done
recovered Was disconnected (or the hotspot was stopped); a join attempt succeeded
hotspot_raised No client connection could be recovered; the hotspot is now up
hotspot_busy The hotspot is up and either occupied or not yet due for a retry; nothing was touched
failed An unexpected failure while acting; the loop keeps running

Verified live on the Pi in three of these states (docs/verified-on.md): disconnected with nothing known → hotspot_raised; hotspot up with nobody attached → hotspot_busy; already connected → connected. recovered and failed, and the "someone is actually attached" branch of hotspot_busy, are covered by unit tests but were not exercised with real hardware this release.

Testing the CLI without hardware

bin/wifi reads two environment variables as a test hook: WIFI_FAKE_OS (one of Linux, Darwin, Windows) selects the backend, and WIFI_FAKE_RUNNER points at a directory containing a map.json fixture map consumed by FakeCommandRunner (see tests/Cli/CliTest.php and tests/Fixtures/cli/** for real examples). This hook requires a dev install — Sanchescom\WiFi\Test\Support\FakeCommandRunner is not autoloaded in a composer require --no-dev install.

API reference

WiFi facade (Sanchescom\WiFi\WiFi)

Method Returns Description
WiFi::create(?CommandRunner $runner = null) self (static) Auto-detects the OS and builds the matching backend
new WiFi(Backend $backend) self Construct directly over a given backend (tests, custom runners)
scan() NetworkCollection Scan for surrounding networks
connect(Network|string $network, Credentials $credentials, ?Device $device = null) void Connect; a hidden/redacted Network throws InvalidArgument — pass the SSID as a string instead
connectTo(string $ssid, Credentials $credentials, ?Device $device = null) void Join $ssid without scanning first — for callers who already know the SSID and want to skip the scan (custom backends, scripted flows); a wrong SSID then surfaces as the backend's own failure: NetworkNotFound on NmcliBackend, a CommandFailed naming the last wpa_state on WpaCliBackend
disconnect(?Device $device = null) void Disconnect
device() Device The detected wireless device
knownNetworks() list<KnownNetwork> Saved connections (Linux only; UnsupportedOperation elsewhere)
forget(KnownNetwork|string $ssid) void Remove a known network (Linux only)
startHotspot(HotspotConfig $config) Hotspot Start a hotspot (Linux only)
stopHotspot() void Stop the hotspot (Linux only)
isHotspotActive() bool Whether a hotspot is currently active (Linux only)
supports(string $capabilityInterface) bool Whether the active backend implements SupportsKnownNetworks::class / SupportsHotspot::class
backend() Backend The underlying backend instance

NetworkCollection (extends Illuminate\Support\Collection<int, Network>)

Method Returns Description
bySsid(string $ssid) Network First match, or NetworkNotFound
byBssid(Bssid|string $bssid) Network First match, or NetworkNotFound
connected() static Only connected networks
band(Band $band) static Only networks on the given band
security(Security $security) static Only networks with the given security
strongerThan(Signal|float $threshold) static Networks at or above a dBm threshold
sortBySignal() static Strongest first; networks with no reading last
strongest() Network The strongest network with a signal reading, or NetworkNotFound
uniqueBySsid() static One row per SSID (strongest radio); hidden names are never merged
hasHiddenSsids() bool Whether any network's SSID is hidden

Every other Illuminate\Support\Collection method (all(), first(), filter(), map(), …) is inherited and works normally.

Note

strongerThan() compares Signal->dbm. On Linux and Windows this value is derived from a 0–100 quality percentage (Signal::fromQuality()) and therefore never exceeds −50 dBm, so a threshold above −50 matches nothing on those platforms; macOS reports a real RSSI value instead.

Network (readonly)

Property Type Notes
$ssid string Empty when the network is hidden
$ssidHidden bool True for a hidden SSID or a redacted one (macOS without Location Services)
$bssid ?Bssid Never available on macOS
$channel ?int null when unknown
$band ?Band Band::GHz2_4 / GHz5 / GHz6, or null
$frequency ?int MHz, or null
$signal ?Signal ->dbm and ->quality, or null when the tool reported none
$security Security Enum: WPA3/WPA2/WPA/WEP/Open/Unknown
$securityFlags string Raw flags string as the tool printed it
$connected bool

HotspotConfig (readonly)

new HotspotConfig(ssid: …, password: …, band: …, device: …, channel: …, country: …, captivePortal: …)

Property Type Notes
$ssid string 1–32 bytes
$password string 8–63 characters; the hotspot is WPA2
$band ?Band GHz2_4 or GHz5; GHz6 is refused by both backends
$device ?Device Auto-detected when null
$channel ?int Must exist on $band; given alone, it brings its band (resolvedBand())
$country ?string Two upper-case letters. WpaCliBackend only
$captivePortal bool The hotspot's DNS answers every name with HotspotConfig::ADDRESS (10.42.0.1). Needs root on NmcliBackend

Exceptions (Sanchescom\WiFi\Exception)

All extend WiFiException.

Exception Extends Thrown when
CommandFailed WiFiException A tool exited non-zero or reported failure. $command and $result are attached; the message masks secrets
PermissionDenied CommandFailed The process may not control NetworkManager or wpa_supplicant
WrongPassphrase CommandFailed Linux: the network refused the passphrase, or it cannot be one (not 8–63 printable ASCII characters, WpaCliBackend)
NoAddress CommandFailed Linux: joined, but no DHCP lease
NetworkNotFound WiFiException No such network in the scan, no such saved network, or nmcli found none to join
DeviceNotFound WiFiException No wireless device
UnsupportedOperation WiFiException The backend lacks the capability, a 6 GHz hotspot, or a hotspot country on NmcliBackend
InvalidArgument WiFiException A value that cannot be used: an empty SSID, a hidden network, a hotspot channel that is not on its band

Provision\Portal

new Portal(WiFi $wifi, ?State $state = null)

Method Returns Description
networks() list<array{ssid, band, quality, security}> Networks in range, strongest first, one per name; the list a State cached when there is one
connect(string $ssid, string $password = '') array{ok, ssid, reason, message} Joins; never throws. Stops a hotspot this device is serving first, then looks for the network for up to a quarter of a minute
status() array{hotspot, joining, connected, lastAttempt} Never scans while a hotspot is up
handle(string $method, string $path, array $input = []) ?Response The three JSON endpoints; null for any other path
Portal::describe(NetworkCollection $networks) the same rows as networks() Static; for a caller that already has a scan

reason is null on success, otherwise one of:

reason From message
wrong_passphrase WrongPassphrase The passphrase for "Home" was not accepted.
network_not_found NetworkNotFound No network named "Home" was found in the scan.
no_address NoAddress Joined "Home", but the network gave the device no address.
permission_denied PermissionDenied The device is not allowed to change its Wi-Fi settings.
invalid InvalidArgument, or no SSID in the request what was wrong with the value
failed anything else The device could not join the network. — the detail goes to the error log
Endpoint Status Body
GET /api/networks 200 {"networks": [{"ssid": "Home", "band": "5", "quality": 87, "security": "WPA2"}]}
GET /api/status 200 {"hotspot": false, "joining": false, "connected": "Home", "lastAttempt": null}
POST /api/connect (ssid, password) 200, 400 without an SSID, 422 when the join failed {"ok": false, "ssid": "Home", "reason": "wrong_passphrase", "message": "…"}

The rest of Sanchescom\WiFi\Provision:

Class
Response $status, $headers, $body, and $after — work to run once the answer has been sent. send() for plain PHP
SetupPage The page wifi provision serves: handle($method, $host, $path, $input): Response. Redirects any request for a foreign host to itself, answers the form before the join
Supervisor start(), tick(): SupervisorState, stop() — keeps the setup hotspot up, and leaves it alone while a join runs
State The cached scan, the "join is running" marker and the last attempt's outcome, in /run/php-wifi. Never a passphrase
QrCode payload($ssid, $password) — the WIFI:T:WPA;… string; render() draws it with qrencode
LocalName detect() — <hostname>.local when avahi-daemon is running

Contributing

Please read CONTRIBUTING.md for details on our code of conduct, and the process for submitting pull requests to us.

Versioning

We use SemVer for versioning. For the versions available, see the tags on this repository.

Authors

See also the list of contributors who participated in this project.

License

This project is licensed under the MIT License — see the LICENSE.md file for details.

Versions up to and including 2.0.0 were published under GPL-3.0. 2.0.1 relicenses the package to MIT; the change is made by the sole author and copyright holder.

About

Cross-platform PHP library for Wi-Fi: scan, join, known networks, and on Linux a hotspot and a watchdog that keeps a headless device (Raspberry Pi) online. Verified on real hardware.

Topics

Resources

Contributing

Stars

52 stars

Watchers

3 watching

Forks

Releases

Packages

Used by

Contributors

Languages