The official PHP client for the Klaracase REST API. Use it to search German court decisions and statutes, read them, follow their citations, and check the citations in a legal text.
Klaracase covers German law only. It is a research tool. It gives no legal advice and does not replace a
lawyer's review. A not_found verdict means the citation was not found in the corpus; it is not a claim that the
citation is fabricated.
- Package:
klaracase/klaracase(Packagist) - PHP 8.2 or newer
- HTTP through any PSR-18 client and PSR-17 factories
- Plain PHP. No framework needed.
- Typed responses: every object, at every depth, is a
final readonlyclass. PHPStan level max passes on your code.
This client is a REST client. It does not speak MCP. If your agent or app wants tools, connect its MCP client to
https://klaracase.de/mcp instead. The same token works there.
composer require klaracase/klaracaseThe package needs a PSR-18 HTTP client and a PSR-17 factory. Composer refuses the install when your project has none. Install these two if you have no preference:
composer require guzzlehttp/guzzle nyholm/psr7Guzzle is the best fit: with it, the timeout settings take effect (see Request options). Other PSR-18 clients, such
as symfony/http-client with its Psr18Client, work too.
- Sign in at https://klaracase.de/settings/api-tokens (your email must be verified).
- Create a token. The token belongs to the organization you have open. Every call is billed to that organization.
- Pick the abilities the token needs:
search,read,graph,datapackage,citation-check. All five are ticked by default. - Pick an expiry. The default is 365 days.
Keep the token out of your code. Put it in the environment:
export KLARACASE_API_TOKEN=klc_your_token_here<?php
declare(strict_types=1);
use Klaracase\Klaracase;
require __DIR__ . '/vendor/autoload.php';
$client = Klaracase::fromEnv(); // reads KLARACASE_API_TOKEN; or Klaracase::client($token)
$result = $client->decisions->search('Eigenbedarf Kündigung', court: 'BGH', limit: 5);
foreach ($result->results ?? [] as $hit) {
echo $hit->aktenzeichen, ' ', $hit->court, ' ', $hit->date, PHP_EOL;
}
echo 'Credits left: ', $result->credits?->credits_remaining, PHP_EOL;$hit is a DecisionHit. Its properties carry the API's key names (aktenzeichen, document_type) and are
nullable, because a key may be missing. tests/typing/consumer.php in this repository is the same code, written for
PHPStan level max, and CI analyses it.
use Klaracase\Enum\Branch;
use Klaracase\Enum\Direction;
use Klaracase\Enum\FacetField;
use Klaracase\Enum\Section;
use Klaracase\Enum\Statute;
// Statutes and decisions together
$client->search('Datenschutz Einwilligung', jurisdiction: 'EU', limit: 3);
// Filters take enums, or plain strings. Dates take 'YYYY-MM-DD' or any DateTimeInterface.
$client->decisions->search(
'Untreue',
branch: Branch::Ordentlich,
citedStatutes: [Statute::StGB, 'StPO'],
dateFrom: new DateTimeImmutable('2020-01-01'),
);
// One decision: a UUID, an Aktenzeichen or an ECLI
$decision = $client->decisions->get('VIII ZR 271/17', section: Section::Gruende, maxChars: 5000);
echo $decision->full_text;
// The citation graph around a decision
$graph = $client->decisions->citations('VIII ZR 271/17', direction: Direction::Incoming);
// One statute norm
$norm = $client->norms->get('§ 535 BGB');
$article = $client->norms->get('Art. 6 DSGVO', jurisdiction: 'EU');
// Up to 10 decisions in one bundle
$package = $client->datapackages->create(['VIII ZR 271/17', '1 StR 185/01'], fullText: false);
// What the corpus holds (free)
$client->corpus->coverage(branch: Branch::Ordentlich, top: 5);
$client->corpus->facets(FacetField::CourtName, prefix: 'BG');An argument you leave out, or set to null, is not sent. Path values, such as an Aktenzeichen with a slash, are
percent-encoded for you.
The client refuses some wrong arguments before it sends a request. It throws ValidationException with the code
client_validation for: no ids or more than 10 ids, both or neither of text and documentId, a date that is not
YYYY-MM-DD, dateFrom after dateTo, and rnFrom after rnTo. It does not check enum membership or ranges the
server owns; the server answers those with a 422.
Klaracase::client($token) covers the common case. For more settings use the factory. Every with... call returns
a changed copy.
$client = Klaracase::factory()
->withToken($token) // default: the KLARACASE_API_TOKEN variable
->withBaseUrl('https://klaracase.de/api/v1')
->withTimeout(10.0) // Guzzle clients; see Request options
->withMaxRetries(1)
->withUserAgentSuffix('my-app/2.1') // appended to the User-Agent
->withHeader('X-Team', 'research') // sent with every request
->withLogger($psr3Logger) // see Logging
->withHttpClient($psr18Client) // also withRequestFactory(), withStreamFactory()
->withMiddleware($traceMiddleware) // wraps every HTTP attempt
->make();A middleware is fn (RequestInterface $request, callable $next): ResponseInterface. The first one added is the
outermost. withDeprecationHandler(), withSleeper() and withClock() complete the list. new Klaracase(...) with
the same named arguments still works.
Every method takes options: RequestOptions::with(...) as its last argument. The same object works as a client
default through the factory (withTimeout, withMaxRetries, withHeader).
use Klaracase\RequestOptions;
$client->citationChecks->create(
text: $text,
options: RequestOptions::with(
timeout: 60.0, // seconds
maxRetries: 0, // for this call only
extraHeaders: ['X-Request-Id' => $id],
extraQuery: ['trace' => '1'],
),
);The per-call timeout reaches the HTTP client when it is a Guzzle client, which is what the SDK builds when Guzzle
is installed. PSR-18 has no hook for a timeout per request. With any other client the timeout of that client stays in
force, and you set it when you build that client.
search() takes page. To walk the pages, use a generator:
foreach ($client->decisions->searchAll('Untreue', maxPages: 3, court: 'BGH') as $hit) {
echo $hit->aktenzeichen, PHP_EOL;
}
foreach ($client->decisions->citationsAll('VIII ZR 271/17', maxPages: 2) as $edge) {
echo $edge->reference_canonical, PHP_EOL;
}Paging the same query within a credit period costs no extra credits. A changed query, filter or mode is a new charge.
Check credits on each response.
maxPages is required. It guards against a runaway walk. The client clamps it to 10 for search and 20 for
citations, the last pages the server serves. The walk stops when a page is empty or the totals say no page follows,
and stops fetching when you stop reading. A result also has hasMore() and nextPage() if you page by hand.
use Klaracase\Enum\Verdict;
$run = $client->citationChecks->create(
text: $text, // 50 to 20,000 characters of German legal text
referenceDate: '2024-05-01', // optional; a DateTimeInterface works too
);
foreach ($run->findings ?? [] as $finding) {
$cited = $finding->excerpt($text); // the span of your text this finding points at
switch ($finding->verdictEnum()) {
case Verdict::VerifiedCorpus:
case Verdict::VerifiedExternal:
// The citation exists. This does not prove the quote or the legal point is right.
echo "found: $cited\n";
break;
case Verdict::NotFound:
// Not in the corpus. This does not mean the citation is invented.
echo "not found in the corpus: $cited\n";
break;
default:
// out_of_coverage, ambiguous, unverifiable, discrepancy, or a verdict added later (Verdict::Unknown).
echo "{$finding->verdict}: $cited\n";
}
}
echo $run->count(Verdict::NotFound), " citations not found\n";
$byVerdict = $run->findingsByVerdict(); // array<string, list<Finding>>excerpt() cuts by Unicode code points, because the offsets (start_offset, end_offset) count code points, not
bytes. The price depends on the length of the text. Checking the same text again in the same credit period costs nothing
more.
// A file on disk. The name and content type come from the path.
$run = $client->citationChecks->createFromFile('schriftsatz.pdf', referenceDate: '2024-05-01');
// The same in steps
$receipt = $client->citationCheckDocuments->upload('schriftsatz.pdf');
$ready = $client->citationCheckDocuments->waitUntilReady($receipt, pollInterval: 2.0, timeout: 120.0);
echo 'Price: ', $ready->quoted_credits, " credits\n";
$run = $client->citationChecks->create(documentId: $ready->id);
// Bytes you already hold
$receipt = $client->citationCheckDocuments->uploadBytes($bytes, 'schriftsatz.pdf');A string given to upload() is a path, and the file is read. It is never sent as content. A path that does not
exist or cannot be read throws ValidationException (code client_validation) before any request. Use
uploadBytes() to send bytes.
Uploads are free. PDF, DOCX, Markdown and plain text are accepted, up to 25 MB. An unused upload expires after 48
hours. waitUntilReady() takes a receipt or an id. It throws DocumentFailedException when extraction fails, and
DocumentTimeoutException when the time runs out.
Send exactly one of text and documentId. The client refuses both or neither before it sends a request.
Every API error is an exception that extends Klaracase\Exception\KlaracaseException. Both it and
ConfigurationException implement the marker interface KlaracaseThrowable, so one catch can cover both.
use Klaracase\Exception\InsufficientCreditsException;
use Klaracase\Exception\KlaracaseException;
use Klaracase\Exception\RateLimitException;
use Klaracase\Exception\TokenLimitReachedException;
use Klaracase\Exception\ValidationException;
try {
$run = $client->citationChecks->create(text: $text);
} catch (TokenLimitReachedException $e) { // before InsufficientCreditsException: it is a subclass
echo "This API key reached its limit of {$e->cap} credits. It resets at {$e->resetsAt}.\n";
} catch (InsufficientCreditsException $e) {
echo "This call costs {$e->cost} credits. You have {$e->balance}.\n"; // reason: $e->reason
} catch (RateLimitException $e) {
echo 'Try again in ', $e->retryAfterSeconds ?? 0, " seconds.\n"; // null when the server named no wait
} catch (ValidationException $e) {
print_r($e->errors); // field name => list of messages
} catch (KlaracaseException $e) {
if ($e->isRetryable()) {
// a rate limit (not the daily volume cap), a 503, or no answer at all
}
error_log(json_encode($e->toArray(), JSON_THROW_ON_ERROR)); // code, status, detail, retry_after_seconds
}| Class | When |
|---|---|
AuthenticationException |
401 |
ForbiddenException |
403, with requiredAbility when a token ability is missing |
NotFoundException |
404 |
ConflictException |
409 |
ValidationException |
422, with errors; also raised before any request when your arguments are wrong (errorCode is client_validation) |
InsufficientCreditsException |
402, with cost, balance, and often reason and topUpUrl (see Credits, API budget and limits) |
TokenLimitReachedException |
402 token_limit_reached: the limit of this API key is used up. A subclass of InsufficientCreditsException, with unit, cap, spent, resetsAt, resetsAtDateTime and settingsUrl, and no cost or balance |
RateLimitException |
429, with retryAfterSeconds (nullable) |
ServiceUnavailableException |
503 |
ApiException |
any other status |
ResponseShapeException |
a success answer without id, reference or field |
TransportException |
no answer came: connection, DNS, TLS |
TimeoutException |
a TransportException for a timeout |
DocumentFailedException |
waitUntilReady() saw status failed; read document |
DocumentTimeoutException |
waitUntilReady() ran out of time; read document |
ConfigurationException |
no token, a bad base URL, or no HTTP client. It does not extend KlaracaseException. |
Fields on every KlaracaseException: status, type, title, detail, errorCode (the problem code),
apiRelease, headers (lower-case names, never the token), body (the whole problem) and extra (every member that
is not type, title, status, detail or code). Typed shortcuts read from the body, null when absent:
candidates (an ambiguous Aktenzeichen), successor (a superseded statute), and unit, cap and resetsAt (a
volume_cap or a token_limit_reached). The message is the problem detail.
isRetryable() is true for a 429 that is not a volume_cap, for a 503, and for a failure to reach the API. It is
false for everything else. retryAfterSeconds stays nullable: write $e->retryAfterSeconds ?? 0.
If the server answers with something that is not a problem body, such as an HTML 502 from a proxy, you get an
ApiException with errorCode set to unknown. detail holds the first 1,000 characters of the body.
The token never appears in an exception message.
The client retries on HTTP 429 and 503 up to maxRetries times (default 2). It waits retry_after_seconds from the
body, or the Retry-After header, or else an exponential backoff of 0.5 s, 1 s, 2 s with full jitter, capped at
8 s. Both statuses mean that nothing ran and nothing was charged, so a retry is safe for every call, including POST.
- A wait the server states above 30 seconds is not slept. The client throws
RateLimitExceptionat once, and you decide. volume_cap(a daily limit) is also thrown at once.- A 402 (
insufficient_creditsandtoken_limit_reached) or any other 4xx is never retried. A 500 or 502 is not retried. - A network error with no answer is retried for GET only. A POST whose connection dropped is not repeated on its own. Repeating it by hand is free in the same period, because one charge covers each operation and subject once per credit period.
$response->meta->attempts and ->retriedFor tell you how many requests a call took and how many seconds the
client waited.
Rate limits: 60 requests a minute per token. Three calls are limited to 10 a minute on top of that:
datapackages->create, citationChecks->create and citationCheckDocuments->upload.
Give the factory a PSR-3 logger. The client writes one debug line per retry (method, path, status, attempt, wait) and one warning when the API announces a deprecation. It never logs the token, a header value or a body.
$client = Klaracase::factory()->withToken($token)->withLogger($logger)->make();A queue worker has its own retry loop. Turn the client's retries off and hand the wait back to the queue:
public function handle(): void
{
$client = Klaracase::factory()->withToken($this->token)->withMaxRetries(0)->make();
try {
$client->citationChecks->create(text: $this->text);
} catch (RateLimitException|ServiceUnavailableException|TransportException $e) {
if ($e->isRetryable()) {
$this->release($e instanceof RateLimitException ? ($e->retryAfterSeconds ?? 60) : 60);
return;
}
throw $e;
}
}release() is the Laravel queue call; other queues have an equivalent.
Do not release a job for a 402. An InsufficientCreditsException or a TokenLimitReachedException will fail again until
a person changes something. Fail the job and tell your user.
Most calls cost credits. These do not: corpus->coverage, corpus->facets, citationChecks->get,
citationCheckDocuments->upload and ->get.
| Call | Credits |
|---|---|
decisions->search, search, decisions->get, norms->get |
1 |
decisions->citations |
2 |
datapackages->create |
5 |
citationChecks->create |
2 for up to 10,000 characters, 3 up to 15,000, 4 up to 20,000 |
Every served answer has credits with metered, cost and credits_remaining. cost and credits_remaining are
null when the account is not metered.
Credits renew per credit period: the UTC calendar month for free organizations, the paid billing period for subscribers. Unused credits do not roll over.
Your plan gives the organization a number of plan credits for each credit period. Plan credits are spent first.
credits_remaining counts plan credits only.
Organizations on the Professional or Enterprise plan can also hold a prepaid API budget. It pays for calls on the API and on the MCP server once the plan credits are used up. The web app never draws on it. The budget does not expire. A call is paid whole from the budget or not at all. For the plans and the price of one credit from the budget, see https://klaracase.de/preise.
When an organization has a budget, or may load one, the credits object of each answer has an api_budget value:
$credits = $client->decisions->search(query: 'Untreue')->credits;
echo $credits?->cost, PHP_EOL; // plan credits and budget together
echo $credits?->credits_remaining, PHP_EOL; // plan credits only
$budget = $credits?->api_budget; // ?ApiBudget
if ($budget !== null) {
echo $budget->cents, PHP_EOL; // budget left, in euro cents (can be negative)
echo $budget->rate_cents_per_credit, PHP_EOL; // what one credit costs from the budget, in euro cents
echo $budget->credits_at_rate, PHP_EOL; // further credits the budget pays for at that rate
}api_budget is absent when the organization has no budget and may not load one. It is never null when it is present.
Treat a missing api_budget as "no budget", never as zero.
An organization admin can set a credit limit for one API key. The limit counts the credits that key uses in the credit
period, plan credits and budget together. A call that would pass the limit is refused with token_limit_reached.
Other keys of the organization are not affected.
Every refusal below is a 402. The call did not run and nothing was charged. The client never retries a 402. A program should stop and report the refusal to its user. It cannot fix a 402 itself.
| Refusal | Exception | Meaning |
|---|---|---|
insufficient_credits, reason is api_budget_empty |
InsufficientCreditsException |
The plan credits are used up. The organization may load a budget, and what it holds does not cover this call. |
insufficient_credits, reason is api_budget_not_available |
InsufficientCreditsException |
The plan credits are used up. The organization has no budget to draw on, or its budget is on hold, and its plan does not allow a top-up. |
insufficient_credits, no reason |
InsufficientCreditsException |
The plan credits are used up. |
token_limit_reached |
TokenLimitReachedException |
This API key used up its own limit. unit is credits, cap is the limit, spent is what the key used, and resetsAt is when its period ends. |
The reason list is open. plan_credits_exhausted is reserved. If reason has a value you do not know, treat it like
api_budget_not_available. reasonEnum() gives InsufficientCreditsReason, with an Unknown case for such a value.
try {
$client->decisions->search(query: 'Untreue');
} catch (TokenLimitReachedException $e) {
// Tell your user which key is capped, and when its count starts again.
report("This API key reached its limit of {$e->cap} credits. It resets at {$e->resetsAt}.");
if ($e->settingsUrl !== null) {
report("An organization admin can change the limit: {$e->settingsUrl}");
}
} catch (InsufficientCreditsException $e) {
report('The organization has no credits left for this call.');
if ($e->topUpUrl !== null) {
report("An organization admin can top up the API budget: {$e->topUpUrl}");
}
}topUpUrl and settingsUrl are for a human organization admin. Show them to your user. A program or an AI agent must
never open them or follow them. This client never opens them.
Each call returns a final readonly class. Nested objects are classes too (Finding, CitationEdge, Count), and
lists are plain list<T>. Property names are the API's key names, unchanged ($result->total_available->value,
$run->verdict_counts).
$result->meta->status; // HTTP status
$result->meta->apiRelease; // X-Klaracase-Api-Release, or null
$result->meta->requestDuration; // seconds
$result->meta->headers; // response headers, lower-case names
$result->credits?->cost;
$result->extra; // keys this client version does not model
$result->toArray(); // the body, with the keys in model order and unknown keys last
json_encode($result); // the same body as JSON; {} stays {}The API only adds keys. A new key lands in extra at its depth and never breaks your code. If a key is missing, or
holds an unexpected JSON type, its property is null; the raw value of a wrong-typed key stays in extra, and
nulls lists the keys that came as an explicit null. Identity keys (id, reference, field) are the exception:
a success answer without them throws ResponseShapeException.
Enum values stay raw strings on the property. Each has an accessor that returns an enum:
$finding->verdictEnum(), $run->statusEnum(), $result->modeEnum(). For values the server may extend, such as
verdicts and statuses, the enum has an Unknown case, so a new server value never throws. For the filters that you
also send (Mode, Branch, DocumentType, Direction), an unknown value gives null.
$response['key'] still reads the body like an array. It is kept for old code and marked deprecated.
Klaracase\Testing\FakeClient answers from a queue and records what your code sent. It implements
Klaracase\Client, an interface with the six resources as methods ($client->decisions()->search(...)), so type-hint
Client where you want to swap it.
use Klaracase\Testing\FakeClient;
use Klaracase\Testing\Fixtures;
use Klaracase\Testing\RecordedRequest;
$client = new FakeClient();
$client->queue(Fixtures::decisionSearch());
$client->queue(Fixtures::problem(429, 'rate_limited', ['retry_after_seconds' => 3]));
$result = $client->decisions->search('Eigenbedarf', court: 'BGH');
// the next call throws RateLimitException
$client->assertSentCount(1);
$client->assertSent(fn (RecordedRequest $r) => $r->path === '/decisions/search' && $r->query['court'] === 'BGH');
$client->assertNothingSent();You can queue a response object, a problem array or an exception. Fixtures holds the example bodies of the API
documentation as response objects. The sleeper and the clock of a FakeClient do not wait, so waitUntilReady() and
createFromFile() run in no time. Testing\RecordingSleeper and Testing\FakeClock are public if you build a
Klaracase yourself with withSleeper() and withClock(). Mocks of the Client interface work too.
To test the real client against HTTP, use a PSR-18 mock such as php-http/mock-client, as this repository does.
composer install
composer check # Pint, PHPStan level max, PHPUnitA smoke script runs the two free endpoints against the live API. It needs a token and is not part of the tests:
KLARACASE_API_TOKEN=klc_your_token_here composer smokeWhen an endpoint is deprecated, the API announces it with Deprecation and Sunset headers. The client calls the
deprecation hook once per process. The default raises an E_USER_DEPRECATED notice.
use Klaracase\Http\DeprecationNotice;
$client = Klaracase::factory()
->withToken($token)
->withDeprecationHandler(function (DeprecationNotice $notice): void {
error_log($notice->message()); // $notice->deprecation, ->sunset, ->link hold the raw header values
})
->make();The client is plain PHP and has no Laravel dependency, and there is no separate Laravel package. Bind it once in a
service provider. Put the token in config/services.php:
'klaracase' => ['token' => env('KLARACASE_API_TOKEN')],use Klaracase\Client;
use Klaracase\Klaracase;
public function register(): void
{
$this->app->singleton(Klaracase::class, fn () => Klaracase::factory()
->withToken((string) config('services.klaracase.token'))
->withUserAgentSuffix('my-app/1.0')
->withLogger($this->app->make('log'))
->make());
$this->app->alias(Klaracase::class, Client::class);
}Then type-hint Client in a controller, job or command. In tests, $this->app->instance(Client::class, new FakeClient()).
Symfony, Slim and other containers bind it the same way.
- PHP 8.2 to 8.5, with
ext-jsonandext-mbstring - A PSR-18 client and a PSR-17 factory (
psr/http-client-implementation,psr/http-factory-implementation) psr/log1, 2 or 3
The User-Agent is klaracase-php/0.1.0 PHP/<version>.
This SDK is 0.x and tracks API release 0.96.0. In 0.x a minor version may change the public surface; a patch version only fixes bugs. After 1.0 the package follows semantic versioning. Every change is listed in CHANGELOG.md. All Klaracase SDKs bump together when the API contract changes.
The SDK sends no telemetry. The only thing it adds to a request is the User-Agent header
klaracase-php/<version> PHP/<version>, for example klaracase-php/0.1.0 PHP/8.4.1, plus what you add yourself.
Klaracase is not affiliated with any court or with the federal government.
- REST reference: https://klaracase.de/docs/api
- MCP server: https://klaracase.de/mcp (tools guide: https://klaracase.de/docs/mcp)
- Support: [email protected]
- License: MIT. Copyright (c) 2026 SPARQ VENTURES UG.