# PHP og Laravel: en klient med curl og Http-facaden

> Kald Danadresse fra ren PHP med curl eller fra Laravel med en serviceklasse, config, timeouts, retry på 429 og fejlkoder.
>
> https://danadresse.dk/guides/php-laravel · verified 2026-11-02
Denne guide viser to måder at kalde Danadresse fra PHP. Den første er en enkelt fil med curl, som virker i alle PHP-projekter fra 8.1 og op, også WordPress-plugins og ældre kodebaser. Den anden er en Laravel-serviceklasse med Http-facaden, config-fil og controller. Begge dækker autocomplete, opslag på id, datavask, reverse geokodning, timeouts, fejlkoder og 429.

Nøglen ligger på serveren i miljøvariablen `DANADRESSE_API_KEY`. Send den aldrig til browseren.

## Ren PHP med curl

Klassen genbruger ét curl-handle, så forbindelsen til API'et holdes åben mellem kaldene. Nederst i filen er en lille demo, der laver seks kald:

```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
```

Det vigtigste i klassen:

- `CURLOPT_CONNECTTIMEOUT` og `CURLOPT_TIMEOUT` sætter loft over ventetiden. PHP's standard er at vente meget længe, og en hængende PHP-FPM-worker kan ikke betjene andre brugere imens.
- `CURLOPT_HEADERFUNCTION` samler svar-headers op, så klassen kan læse `Retry-After`.
- Fejlteksten, `code` og `details` læses fra begge fejlformater. DAWA-stierne svarer `{"type","title","details","code"}`, og `/api/v1/*` svarer `{"error":{"code","message","details"}}`, for alle fejl, også 500. Forgren på `$e->errorCode`.
- Ved 429 og 503 med `Retry-After` (1 til 60 sekunder ved minutgrænsen) venter klassen og prøver igen, højst to gange. `quota_exceeded` har ingen `Retry-After` og kastes med det samme: en gratiskonto har brugt sin månedskvote og bufferen, eller en betalt konto har nået sit budgetloft. `$e->details['resets_at']` fortæller, hvornår der svares igen, og `$e->details['upgrade_url']`, hvor du opgraderer. Afviste kald tælles og faktureres ikke.

## Laravel

Koden er skrevet til Laravel 10 og nyere. Her lægger du indstillingerne i en config-fil, så de kan caches med `php artisan config:cache`, og du binder klienten som singleton i containeren.

### 1. Konfiguration

Tilføj nøglen til `.env`:

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

Og opret `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),
];
```

Læs altid værdierne med `config('danadresse.api_key')` i din kode. `env()` returnerer `null`, når konfigurationen er cachet.

### 2. Serviceklassen

Http-facaden har timeouts og retry indbygget. Callback'et til `retry()` bestemmer ventetiden og læser `Retry-After`. Det andet callback afgør, hvilke fejl der er værd at prøve igen:

```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]);
    }
}
```

Undtagelsen ligger i sin egen fil, så autoloaderen kan finde den:

```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` gør, at Laravel returnerer det sidste svar efter sidste forsøg i stedet for at kaste sin egen `RequestException`. Så er det altid `DanadresseException` med API'ets `code`, der når din kode.

### 3. Registrér klienten

```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. Brug den i en controller

Controlleren har to ruter: forslag til et adressefelt og en datavask, der afgør, om adressen kan gemmes direkte, eller om brugeren skal vælge:

```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']);
```

Throttle-middlewaren beskytter din egen kvote mod en bruger eller bot, der hamrer løs på forslagsruten.

## Datavask: hvornår må du gemme?

`vask()` returnerer kategorien fra topniveau (A, B eller C), `afstand` og kandidaterne, bedste først. Kategorien findes kun på topniveau, og der er intet score-felt:

| Svar | Betydning | Controlleren svarer |
|---|---|---|
| A | Præcis match | `ok` med adressens id og den officielle tekst |
| B | Præcis én sikker match med små stavefejl | Også `ok`. Gem den officielle tekst, ikke det brugeren skrev |
| C | Usikker: flere kandidater eller større forskelle | `vaelg` med op til fem kandidater, brugeren kan vælge imellem |
| Ingen kandidater | Intet fundet | `ukendt`. Bed brugeren vælge via autocomplete |

I demoen giver "Strandvejen 100, 2900 Hellerup" A med én kandidat, og "Rentemestervej 8, 2. tv, 2400 København NV" giver C med syv, fordi det nummer ikke har en 2. tv. `betegnelse` læser selv etage og dør (`2. tv`, `st. th`). Har din formular allerede separate felter, kan du sende `vejnavn`, `husnr`, `etage`, `dør` og `postnr` i stedet.

Gem adressens `id` på ordren eller kunden, ikke kun teksten. Se [Gem adresse-id'et](/guides/gem-adresse-id) og [Datavask i checkout](/guides/datavask-i-checkout).

## Fejlkoder du bør håndtere

| Status | `code` | Handling |
|---|---|---|
| 401 | `missing_api_key`, `invalid_api_key` | Tjek `.env` og kør `php artisan config:clear` |
| 403 | `key_blocked`, `ip_not_allowed`, `sandbox_scope` | Nøglen er spærret eller har ikke adgang. Se [Fejl 403](/guides/fejl-403) |
| 404 | `not_found` | Id'et findes ikke længere |
| 429 | `rate_limit_per_minute` | Klienten venter selv `Retry-After` sekunder |
| 429 | `quota_exceeded` | Gratis: månedskvoten og bufferen er brugt. Betalt: budgetloftet er nået. Prøv aldrig igen. Vis `$e->details['resets_at']`, og opgradér eller skift loftet på [/dashboard/billing](/dashboard/billing) |
| 500 | `internal_error` | En fejl hos os. Log den, og prøv igen senere |
| 503 | `upstream_unavailable` | Et offentligt register er nede. Klienten prøver igen |

## Næste skridt

- [Grænser, kvoter og 429](/guides/graenser-og-429)
- [Fejlformatet: DAWA-stier og /api/v1](/guides/fejlformat)
- WooCommerce i stedet for Laravel? Se [WooCommerce](/guides/woocommerce).
