dapphp/radius
Pure PHP RADIUS client for authentication, authorization, and accounting. Send Access-Request, Accounting-Request, and CoA/Disconnect packets, with support for vendor-specific attributes, retries, timeouts, and easy integration into any PHP or Laravel application.
Install and inspect
composer require dapphp/radius
The package is a pure-PHP UDP client — no PECL extension or daemon needed for PAP authentication. Before writing code, read in this order:
README.md — the canonical PAP/MSCHAP examples.src/Radius.php — the convenience API (accessRequest(), NAS setters, attribute helpers, timeout/error handling).src/RadiusAccessRequest.php and its subclasses (...PAP, ...CHAP, ...MSCHAPv1, ...MSCHAPv2) — the object-oriented request path you'll need for anything beyond PAP.src/RadiusResponse.php / src/RadiusPacket.php — how attributes are read back (Framed-IP, Filter-Id, Class, Reply-Message).Minimal working call
use Dapphp\Radius\Radius;
$radius = new Radius('10.0.0.10', 'shared-secret', 3, 1812);
$radius->setNasIpAddress('10.0.0.5'); // usually required by the server
$radius->setNasPort(0);
if ($radius->accessRequest($username, $password)) {
// Access-Accept
} else {
// Access-Reject *or* a transport/config error — always inspect the message
logger()->warning('RADIUS failure: ' . $radius->getErrorMessage());
}
First use case
Centralizing credential checks that used to live in users.password — a VPN portal, an admin console, a Wi-Fi captive portal, or a legacy app that must authenticate against FreeRADIUS, Windows NPS, or Cisco ISE. The 80% path is: configure host/secret, set NAS-IP-Address, call accessRequest(), branch on the boolean, and log the error string.
1. Wrap it in a single service, never call it inline
RADIUS is network I/O with a shared secret; it deserves a class and a config entry.
// config/radius.php
return [
'host' => env('RADIUS_HOST', '127.0.0.1'),
'secret' => env('RADIUS_SECRET'),
'port' => (int) env('RADIUS_PORT', 1812),
'timeout' => (int) env('RADIUS_TIMEOUT', 3),
'nas_ip' => env('RADIUS_NAS_IP'),
];
// app/Services/RadiusAuthenticator.php
class RadiusAuthenticator
{
public function authenticate(string $user, string $pass, array $extra = []): RadiusResult
{
$radius = new Radius(config('radius.host'), config('radius.secret'),
config('radius.timeout'), config('radius.port'));
$radius->setNasIpAddress(config('radius.nas_ip'));
foreach ($extra as $type => $value) {
$radius->setAttribute($type, $value);
}
$ok = $radius->accessRequest($user, $pass);
return new RadiusResult($ok, $radius->getErrorMessage());
}
}
Bind it as a singleton/interface so tests can swap in a fake.
2. Custom auth guard / user provider
The cleanest Laravel integration is a retrieveByCredentials() that calls your wrapper instead of Hash::check(). Keep local authorization (roles, tenancy) in the database keyed by username — RADIUS answers "is this password valid", not "what may this user do".
public function retrieveByCredentials(array $credentials)
{
$user = User::where('email', $credentials['email'])->first();
if (! $user) { return null; }
return $this->radius->authenticate($credentials['email'], $credentials['password'])->accepted
? $user
: null;
}
3. Use the request objects when you need more than PAP
For MSCHAPv2 (common with NPS/AD), build the explicit request and keep the response object — it gives you attribute access and accept/reject/challenge discrimination.
$request = new RadiusAccessRequestMSCHAPv2($radius);
$request->setUsername($username);
$request->setPassword($plaintextPassword);
$response = $request->sendRequest();
if ($response->isAccessAccept()) {
$framedIp = $response->getAttribute(8); // Framed-IP-Address
$filterId = $response->getAttribute(11); // Filter-Id
$class = $response->getAttribute(25); // Class (echo back on accounting)
}
4. Handle Access-Challenge explicitly
Some servers (OTP, MFA, password-expiry flows) reply with Access-Challenge + State. Treat that as a distinct third outcome and re-send with the State attribute echoed back rather than collapsing it into "rejected".
5. Vendor-specific attributes
Use the VendorSpecificAttribute helper rather than hand-packing bytes when you need WISPr, Cisco AV-pairs, or MikroTik rate limits. The encoding is vendor-id / vendor-type / length / value, and getting the length or type width wrong produces a server-side parse error that looks like a silent reject.
6. Timeouts and retry budget
Set the socket timeout short (2–3 s) in HTTP requests and treat retries as a bounded budget. For bulk operations (importing users, nightly provisioning checks) push the work onto a queue — a web request that blocks on three UDP retries is a request that holds a PHP-FPM worker.
7. Local development and tests
Run FreeRADIUS in Docker with a users file and start it with radiusd -X to see every packet in the foreground. Point your .env at it. For unit tests, mock your RadiusAuthenticator interface; keep one integration test that hits the container so protocol regressions are caught.
clients.conf.accessRequest() returns false for both. Always log/read getErrorMessage(); otherwise a down server looks like "wrong password".Radius instance across requests without care. Request identifiers increment and attributes you set persist; a fresh instance per authentication is the safest default."10.0.0.1" where an ipaddr is expected (or vice versa) yields cryptic server behavior.Acct-Status-Start/Stop, confirm current support in the source first; plan to build on RadiusPacket/RadiusRequest if it isn't first-class.radclient or tcpdump -i any -n port 1812 -X; also check for the classic port mistake (1812 vs legacy 1645).env()/a secrets manager — the constructor takes it as a string, so it will end up in stack traces if you're careless.composer.json and test upgrades against your RADIUS server, not just the unit suite.How can I help you explore Laravel packages today?