Every internet provider has the same quiet problem: thousands of routers and modems sitting in customers’ homes, each needing configuration, updates and the occasional fix. Doing that by hand, or over the phone with the customer, doesn’t scale.
TR-069 is the standard answer. On ISPKenya, a multi-tenant platform for 10+ ISPs, I integrated GenieACS so providers could provision and manage 1,000+ customer devices from a Laravel dashboard, cutting manual device operations by about 60%. This post explains how TR-069 fits together with a Laravel application, and the patterns I would use again.
TR-069 in two minutes
TR-069 (officially CWMP) defines how customer devices, called CPE, talk to a management server, called an ACS.
- The device starts the conversation. A CPE contacts the ACS on boot, on a schedule (the periodic inform interval) and when something changes. Each session starts with an
Informmessage reporting what the device is and its key parameters. - The ACS answers with work. During that session, the ACS can read or set parameters, reboot the device, push a firmware download, or add and delete objects such as extra Wi-Fi networks.
- The ACS can ask for a session. A connection request tells the device “call me now”, so changes apply in seconds instead of waiting for the next periodic inform. It only works when the ACS can reach the device, which NAT often prevents; that is worth checking early in any rollout.
- Parameters follow a data model. Settings live in a tree, such as
InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSIDin the older TR-098 model orDevice.WiFi.SSID.1.SSIDin TR-181. Different vendors and models expose different branches.
Where GenieACS fits
You could implement CWMP yourself, but you shouldn’t. GenieACS is an open-source ACS that handles the protocol, stores device state in MongoDB, and exposes a REST API, the NBI (northbound interface), for everything else. That split is the key architectural decision:
- GenieACS speaks TR-069 to devices.
- Laravel owns the business: ISPs, customers, plans, staff, permissions, audit history, and the dashboard.
- The NBI is the line between them.
The Laravel app never talks CWMP. It asks GenieACS to do things through the NBI and reads device state back.
A thin client around the NBI
Keep every NBI call in one service class. It is the only place that knows URLs, encoding rules and timeouts.
class GenieAcs
{
public function __construct(private string $baseUrl) {}
public function findDevice(string $serial): ?array
{
$query = json_encode(['_deviceId._SerialNumber' => $serial]);
return Http::baseUrl($this->baseUrl)
->get('/devices/', ['query' => $query])
->throw()
->json(0);
}
public function setParameters(string $deviceId, array $values): bool
{
$response = Http::baseUrl($this->baseUrl)
->withQueryParameters(['connection_request' => '', 'timeout' => 3000])
->post('/devices/'.rawurlencode($deviceId).'/tasks', [
'name' => 'setParameterValues',
'parameterValues' => $values, // [[path, value, type], ...]
])
->throw();
// 200: the device applied it now. 202: queued for its next session.
return $response->status() === 200;
}
}
Two details that save debugging time. GenieACS device IDs contain characters that must be URL-encoded, so always rawurlencode() them. And the task endpoint can answer 200 (executed immediately after a successful connection request) or 202 (queued until the device next checks in). Treat 202 as a normal outcome, not a failure, and show it in the UI as “pending”.
Provisioning as a lifecycle, not a button
“Provision this router” is really a sequence: find the device, link it to the right tenant and customer, apply that customer’s settings, confirm the device reported back, and record what happened. On ISPKenya the platform covered the whole lifecycle, provision, update and delete, including automatic port allocation, so staff no longer stitched those steps together by hand.
In Laravel, that sequence belongs in queued jobs:
- Jobs, not requests. Device operations can take seconds or wait on the device, so the dashboard dispatches a job and shows progress instead of holding an HTTP request open.
- Idempotent steps. Running “apply plan settings” twice should produce the same result. Devices reconnect and jobs retry; neither should create duplicates.
- State you can show. Keep a local record per device with its status (pending, applied, failed) and the last time it was seen, so staff aren’t guessing.
- Tenant on every job. In a multi-tenant platform, the tenant ID travels with the job explicitly and every lookup is scoped to it. A device belongs to exactly one ISP.
Let GenieACS handle the routine
Not everything needs to go through Laravel. GenieACS presets and provisions run scripts automatically when devices inform, which suits defaults every device should get, like the inform interval or a management password. Keep Laravel for anything tied to business data, such as a customer’s Wi-Fi name or their plan, and let presets handle what is the same for every device.
Audit every device action
When staff from several ISPs can change real hardware in customers’ homes, every action needs a trail: who did it, for which tenant, to which device, what changed, and whether the device confirmed it. That log is what you reach for when a customer calls to say their internet stopped working, and on ISPKenya it sat alongside role-based access across 3 roles, so only the right people could act at all.
What I would tell a team starting out
- Use an existing ACS (GenieACS) and keep CWMP out of your application.
- Put every NBI call behind one service class.
- Model provisioning as idempotent queued jobs with visible state.
- Treat “queued until next inform” as normal.
- Scope everything to the tenant, and audit every action.
- Test on each router model you support early; data models differ more than the spec suggests.
Done this way, device management stops being a support burden and becomes part of the product. The ISPKenya case study covers the rest of the platform, including the multi-tenant architecture behind it.