PHP and Laravel: a client with curl and the Http facade
Call Danadresse from plain PHP with curl or from Laravel with a service class, config, timeouts, retry on 429 and error codes.
This guide shows two ways to call Danadresse from PHP. The first is a single file using curl, which works in any project on PHP 8.1 or later, including WordPress plugins and older codebases. The second is a Laravel service class with the Http facade, a config file and a controller. Both cover autocomplete, lookup by id, datavask (address cleansing), reverse geocoding, timeouts, error codes and 429.
The key lives on the server in the DANADRESSE_API_KEY environment variable. Never send it to the browser.
Plain PHP with curl
The class reuses one curl handle, so the connection to the API stays open between calls. At the bottom of the file is a small demo that makes six calls:
<?php // danadresse.php: en lille klient til Danadresse API med curl (PHP 8.1+) declare(strict_types=1); final class DanadresseException extends RuntimeException { public function __construct( public readonly int $status, public readonly ?string $errorCode, string $message, public readonly ?int $retryAfter = null, public readonly ?array $details = null, // fx resets_at og upgrade_url ved quota_exceeded ) { parent::__construct("HTTP $status $errorCode: $message", $status); } } final class Danadresse { private string $baseUrl; private string $apiKey; private \CurlHandle $ch; public function __construct(?string $apiKey = null, ?string $baseUrl = null, private int $maxRetries = 2) { $this->apiKey = $apiKey ?? (string) getenv('DANADRESSE_API_KEY'); $this->baseUrl = rtrim($baseUrl ?? (getenv('DANADRESSE_BASE_URL') ?: 'https://api.danadresse.dk'), '/'); $this->ch = curl_init(); } public function get(string $path, array $params = []): array { $url = $this->baseUrl . $path . ($params ? '?' . http_build_query($params) : ''); for ($attempt = 0; ; $attempt++) { $headers = []; $ch = $this->ch; curl_reset($ch); // samme handle = genbrugt forbindelse curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_TIMEOUT => 10, CURLOPT_HTTPHEADER => ['X-Api-Key: ' . $this->apiKey, 'Accept: application/json'], CURLOPT_HEADERFUNCTION => function ($ch, string $line) use (&$headers): int { $parts = explode(':', $line, 2); if (count($parts) === 2) { $headers[strtolower(trim($parts[0]))] = trim($parts[1]); } return strlen($line); }, ]); $body = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $curlError = curl_error($ch); if ($body === false) { // timeout eller netværksfejl if ($attempt >= $this->maxRetries) { throw new DanadresseException(0, 'network_error', $curlError); } sleep(2 ** $attempt); continue; } $json = json_decode($body, true); if ($status >= 200 && $status < 300) { return $json; } // DAWA-stier: {type,title,details,code} /api/v1: {error:{code,message}} $err = is_array($json['error'] ?? null) ? $json['error'] : (is_array($json) ? $json : []); $retryAfter = ctype_digit($headers['retry-after'] ?? '') ? (int) $headers['retry-after'] : null; if (in_array($status, [429, 503], true) && $retryAfter !== null && $attempt < $this->maxRetries) { sleep(min($retryAfter, 30)); continue; } throw new DanadresseException($status, $err['code'] ?? null, $err['title'] ?? $err['message'] ?? 'Ukendt fejl', $retryAfter, is_array($err['details'] ?? null) ? $err['details'] : null); } } public function autocomplete(string $q, int $perSide = 5): array { return $this->get('/autocomplete', ['q' => $q, 'per_side' => $perSide]); } public function adresse(string $id): array { return $this->get('/adresser/' . rawurlencode($id)); } /** @return array{kategori: string, afstand: ?int, adresse: ?array, kandidater: list<array>} */ public function vask(string $betegnelse): array { $svar = $this->get('/datavask/adresser', ['betegnelse' => $betegnelse]); $res = $svar['resultater'] ?? []; // aktueladresse = den gældende version (afviger, hvis den fundne er nedlagt) $kandidater = array_map(fn (array $r) => $r['aktueladresse'] ?? $r['adresse'], $res); return [ 'kategori' => $svar['kategori'] ?? 'C', // kun på topniveau, ingen score 'afstand' => $res[0]['vaskeresultat']['afstand'] ?? null, // 0 = præcis 'adresse' => $kandidater[0] ?? null, 'kandidater' => $kandidater, ]; } public function reverse(float $lon, float $lat): array { return $this->get('/adgangsadresser/reverse', ['x' => $lon, 'y' => $lat]); } } // Demo $api = new Danadresse(); foreach ($api->autocomplete('Rådhuspladsen 1, 4200', 3) as $f) { echo 'forslag: ', $f['forslagstekst'], PHP_EOL; } $a = $api->adresse('0a3f50ae-b4ee-32b8-e044-0003ba298018'); [$lon, $lat] = $a['adgangsadresse']['adgangspunkt']['koordinater']; echo 'opslag: ', $a['adressebetegnelse'], " ($lon, $lat)", PHP_EOL; foreach (['Strandvejen 100, 2900 Hellerup', 'Rentemestervej 8, 2. tv, 2400 København NV'] as $tekst) { $v = $api->vask($tekst); echo 'datavask: ', $v['kategori'], ' ', count($v['kandidater']), PHP_EOL; } echo 'reverse: ', $api->reverse(12.5683, 55.6761)['adressebetegnelse'], PHP_EOL; try { $api->adresse('00000000-0000-0000-0000-000000000000'); } catch (DanadresseException $e) { echo 'fejl: ', $e->status, ' ', $e->errorCode, PHP_EOL; }
Output:
forslag: Rådhuspladsen 1, 4200 Slagelse forslag: Rådhuspladsen 1, st. tv, 4200 Slagelse forslag: Rådhuspladsen 1, st. th, 4200 Slagelse opslag: Rådhuspladsen 1, st. th, 4200 Slagelse (11.35623868, 55.40464424) datavask: A 1 datavask: C 7 reverse: Rådhuspladsen 3, 1550 København V fejl: 404 not_found
The important parts of the class:
CURLOPT_CONNECTTIMEOUTandCURLOPT_TIMEOUTcap the waiting time. PHP's default is to wait a very long time, and a hanging PHP-FPM worker cannot serve other users meanwhile.CURLOPT_HEADERFUNCTIONcollects the response headers so the class can readRetry-After.- The error text,
codeanddetailsare read from both error formats. The DAWA paths answer{"type","title","details","code"}, and/api/v1/*answers{"error":{"code","message","details"}}, for every error including 500. Branch on$e->errorCode. - On 429 and 503 with
Retry-After(1 to 60 seconds on the per-minute limit), the class waits and retries, at most twice.quota_exceededhas noRetry-Afterand is thrown immediately: a free account has used its monthly quota and the grace buffer, or a paid account has reached its budget cap.$e->details['resets_at']says when calls are served again, and$e->details['upgrade_url']where to upgrade. Refused calls are not counted or billed.
Laravel
The code targets Laravel 10 and later. You put the settings in a config file, so they can be cached with php artisan config:cache, and you bind the client as a singleton in the container.
1. Configuration
Add the key to .env:
DANADRESSE_API_KEY=dawa_live_din_nøgle_her
DANADRESSE_TIMEOUT=10And create config/danadresse.php:
<?php // config/danadresse.php return [ 'api_key' => env('DANADRESSE_API_KEY'), 'base_url' => env('DANADRESSE_BASE_URL', 'https://api.danadresse.dk'), 'timeout' => (int) env('DANADRESSE_TIMEOUT', 10), ];
Always read the values with config('danadresse.api_key') in your code. env() returns null once the configuration is cached.
2. The service class
The Http facade has timeouts and retry built in. The retry() callback decides the delay and reads Retry-After. The second callback decides which errors are worth retrying:
<?php // app/Services/Danadresse.php declare(strict_types=1); namespace App\Services; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Throwable; final class Danadresse { /** @param array{api_key: string, base_url: string, timeout: int} $config */ public function __construct(private readonly array $config) { } private function http(): PendingRequest { return Http::baseUrl($this->config['base_url']) ->withHeaders(['X-Api-Key' => $this->config['api_key']]) ->acceptJson() ->connectTimeout(3) ->timeout($this->config['timeout']) ->retry( 3, // Ventetid: Retry-After fra svaret, ellers 0,5 s, 1 s … function (int $attempt, Throwable $e): int { if ($e instanceof RequestException) { $ra = $e->response->header('Retry-After'); if (ctype_digit($ra)) { return min((int) $ra, 30) * 1000; } } return 500 * $attempt; }, // Prøv kun igen ved netværksfejl og 429/503 med Retry-After. // quota_exceeded (429 uden Retry-After) gives videre med det samme. function (Throwable $e): bool { if ($e instanceof ConnectionException) { return true; } return $e instanceof RequestException && in_array($e->response->status(), [429, 503], true) && $e->response->header('Retry-After') !== ''; }, throw: false, ); } private function get(string $path, array $query = []): array { $response = $this->http()->get($path, $query); if ($response->failed()) { throw DanadresseException::fromResponse($response); } return $response->json(); } public function autocomplete(string $q, int $perSide = 5): array { return $this->get('/autocomplete', ['q' => $q, 'per_side' => $perSide]); } public function adresse(string $id): array { // En adresse ændrer sig sjældent: cache opslaget en time return Cache::remember("danadresse:adresse:$id", 3600, fn () => $this->get('/adresser/' . rawurlencode($id))); } /** @return array{kategori: string, afstand: ?int, adresse: ?array, kandidater: list<array>} */ public function vask(string $betegnelse): array { $svar = $this->get('/datavask/adresser', ['betegnelse' => $betegnelse]); $res = $svar['resultater'] ?? []; // aktueladresse = den gældende version (afviger, hvis den fundne er nedlagt) $kandidater = array_map(fn (array $r) => $r['aktueladresse'] ?? $r['adresse'], $res); return [ 'kategori' => $svar['kategori'] ?? 'C', // kun på topniveau, ingen score 'afstand' => $res[0]['vaskeresultat']['afstand'] ?? null, // 0 = præcis 'adresse' => $kandidater[0] ?? null, 'kandidater' => $kandidater, ]; } public function reverse(float $lon, float $lat): array { return $this->get('/adgangsadresser/reverse', ['x' => $lon, 'y' => $lat]); } }
The exception lives in its own file so the autoloader can find it:
<?php // app/Services/DanadresseException.php declare(strict_types=1); namespace App\Services; use Illuminate\Http\Client\Response; use RuntimeException; final class DanadresseException extends RuntimeException { public function __construct( public readonly int $status, public readonly ?string $errorCode, string $message, public readonly ?array $details = null, ) { parent::__construct("HTTP $status $errorCode: $message", $status); } public static function fromResponse(Response $response): self { // DAWA-stier: {type,title,details,code} /api/v1: {error:{code,message,details}} $details = $response->json('details') ?? $response->json('error.details'); return new self( $response->status(), $response->json('code') ?? $response->json('error.code'), (string) ($response->json('title') ?? $response->json('error.message') ?? $response->reason()), is_array($details) ? $details : null, ); } }
throw: false makes Laravel return the last response after the final attempt instead of throwing its own RequestException. That way it is always a DanadresseException carrying the API's code that reaches your code.
3. Register the client
<?php // app/Providers/AppServiceProvider.php declare(strict_types=1); namespace App\Providers; use App\Services\Danadresse; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider { public function register(): void { $this->app->singleton(Danadresse::class, fn () => new Danadresse(config('danadresse'))); } }
4. Use it in a controller
The controller has two routes: suggestions for an address field, and a datavask that decides whether the address can be stored directly or the user must pick:
<?php // app/Http/Controllers/AdresseController.php declare(strict_types=1); namespace App\Http\Controllers; use App\Services\Danadresse; use App\Services\DanadresseException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; final class AdresseController extends Controller { public function __construct(private readonly Danadresse $api) { } // GET /adresse/forslag?q=… (browseren kalder din server, nøglen bliver her) public function forslag(Request $request): JsonResponse { $q = trim((string) $request->query('q', '')); if (mb_strlen($q) < 2) { return response()->json([]); } try { $forslag = $this->api->autocomplete($q, 8); } catch (DanadresseException $e) { report($e); return response()->json(['error' => $e->errorCode], 502); } return response()->json(array_map(fn (array $f) => [ 'tekst' => $f['forslagstekst'], 'id' => $f['data']['id'] ?? null, ], $forslag)); } // POST /adresse/vask {"adresse": "Strandvejen 100, 2900 Hellerup"} public function vask(Request $request): JsonResponse { $data = $request->validate(['adresse' => ['required', 'string', 'max:200']]); $v = $this->api->vask($data['adresse']); $tekst = fn (array $a) => "{$a['vejnavn']} {$a['husnr']}, {$a['postnr']} {$a['postnrnavn']}"; return match (true) { // A = præcis, B = én sikker match med små stavefejl: gem id og den officielle tekst in_array($v['kategori'], ['A', 'B'], true) && $v['adresse'] !== null => response()->json(['status' => 'ok', 'adresse_id' => $v['adresse']['id'], 'tekst' => $tekst($v['adresse'])]), // C = usikker: lad brugeren vælge mellem kandidaterne $v['kandidater'] !== [] => response()->json(['status' => 'vaelg', 'kandidater' => array_map( fn (array $a) => ['id' => $a['id'], 'tekst' => $tekst($a)], array_slice($v['kandidater'], 0, 5))], 422), default => response()->json(['status' => 'ukendt'], 422), }; } }
// routes/web.php Route::get('/adresse/forslag', [AdresseController::class, 'forslag'])->middleware('throttle:60,1'); Route::post('/adresse/vask', [AdresseController::class, 'vask']);
The throttle middleware protects your own quota against a user or bot hammering the suggestion route.
Datavask: when can you store the address?
vask() returns the top-level category (A, B or C), afstand and the candidates, best first. The category exists only at the top level, and there is no score field:
| Response | Meaning | The controller answers |
|---|---|---|
| A | Exact match | ok with the address id and the official text |
| B | Exactly one safe match with small spelling differences | ok as well. Store the official text, not what the user typed |
| C | Uncertain: several candidates or larger differences | vaelg (pick) with up to five candidates for the user to choose from |
| No candidates | Nothing found | ukendt (unknown). Ask the user to pick via autocomplete |
In the demo, "Strandvejen 100, 2900 Hellerup" gives A with one candidate, and "Rentemestervej 8, 2. tv, 2400 København NV" gives C with seven, because that number has no 2. tv. betegnelse reads floor and door itself (2. tv, st. th). If your form already has separate fields, you can send vejnavn, husnr, etage, dør and postnr instead.
Store the address id on the order or customer, not only the text. See Store the address id and Datavask in checkout.
Error codes to handle
| Status | code | Action |
|---|---|---|
| 401 | missing_api_key, invalid_api_key | Check .env and run php artisan config:clear |
| 403 | key_blocked, ip_not_allowed, sandbox_scope | The key is blocked or lacks access. See Error 403 |
| 404 | not_found | The id no longer exists |
| 429 | rate_limit_per_minute | The client waits Retry-After seconds by itself |
| 429 | quota_exceeded | Free: the monthly quota and the grace buffer are used up. Paid: the budget cap is reached. Never retry. Show $e->details['resets_at'] and upgrade or change the cap at /dashboard/billing |
| 500 | internal_error | An error on our side. Log it and try again later |
| 503 | upstream_unavailable | A public register is down. The client retries |
Next steps
- Limits, quotas and 429
- The error format: DAWA paths and /api/v1
- WooCommerce rather than Laravel? See WooCommerce.
Related guides
Markdown source Spotted an error? Tell us API-reference (Swagger)