# 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.
>
> https://danadresse.dk/en/guides/php-laravel · verified 2026-11-02
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 id=curl-client run=live expect="Rådhuspladsen"
<?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 id=curl-output run=no
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`:

```ini id=laravel-env run=no
DANADRESSE_API_KEY=dawa_live_din_nøgle_her
DANADRESSE_TIMEOUT=10
```

And create `config/danadresse.php`:

```php id=laravel-config run=syntax
<?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 id=laravel-service run=syntax
<?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 id=laravel-exception run=syntax
<?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 id=laravel-provider run=syntax
<?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 id=laravel-controller run=syntax
<?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 id=laravel-routes run=no
// 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](/en/guides/gem-adresse-id) and [Datavask in checkout](/en/guides/datavask-i-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](/en/guides/fejl-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](/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](/en/guides/graenser-og-429)
- [The error format: DAWA paths and /api/v1](/en/guides/fejlformat)
- WooCommerce rather than Laravel? See [WooCommerce](/en/guides/woocommerce).
