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.
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 // 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
Det vigtigste i klassen:
CURLOPT_CONNECTTIMEOUTogCURLOPT_TIMEOUTsæ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_HEADERFUNCTIONsamler svar-headers op, så klassen kan læseRetry-After.- Fejlteksten,
codeogdetailslæ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_exceededhar ingenRetry-Afterog 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:
DANADRESSE_API_KEY=dawa_live_din_nøgle_her
DANADRESSE_TIMEOUT=10Og opret 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), ];
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 // 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 // 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 // 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 // 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']);
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 og 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 |
| 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 |
| 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
- Fejlformatet: DAWA-stier og /api/v1
- WooCommerce i stedet for Laravel? Se WooCommerce.
Relaterede guides
Markdown-kilde Fejl i guiden? Skriv til os API-reference (Swagger)