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.

6 min Level: Intermediate ✓ Code tested

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#curl-client
<?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:

Text#curl-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_CONNECTTIMEOUT and CURLOPT_TIMEOUT cap 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_HEADERFUNCTION collects the response headers so the class can read Retry-After.
  • The error text, code and details are 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_exceeded has no Retry-After and 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:

Config#laravel-env
DANADRESSE_API_KEY=dawa_live_din_nøgle_her
DANADRESSE_TIMEOUT=10

And create config/danadresse.php:

PHP#laravel-config
<?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#laravel-service
<?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#laravel-exception
<?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#laravel-provider
<?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#laravel-controller
<?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),
        };
    }
}
PHP#laravel-routes
// 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:

ResponseMeaningThe controller answers
AExact matchok with the address id and the official text
BExactly one safe match with small spelling differencesok as well. Store the official text, not what the user typed
CUncertain: several candidates or larger differencesvaelg (pick) with up to five candidates for the user to choose from
No candidatesNothing foundukendt (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

StatuscodeAction
401missing_api_key, invalid_api_keyCheck .env and run php artisan config:clear
403key_blocked, ip_not_allowed, sandbox_scopeThe key is blocked or lacks access. See Error 403
404not_foundThe id no longer exists
429rate_limit_per_minuteThe client waits Retry-After seconds by itself
429quota_exceededFree: 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
500internal_errorAn error on our side. Log it and try again later
503upstream_unavailableA public register is down. The client retries

Next steps

Related guides

Markdown source Spotted an error? Tell us API-reference (Swagger)

Try it with a free key

2,000 calls/month on the free plan. No credit card.

Create account →