Build your extension on nr-llm
This guide walks you through adding AI capabilities to a TYPO3 extension using nr-llm as a dependency. By the end, your extension will have working AI features without any provider-specific code.
Why build on nr-llm?
When your extension calls an LLM API directly, it takes on responsibility for:
- HTTP client setup, authentication, and error handling per provider
- Secure API key storage (not in
ext_conf_template.txtor$GLOBALS) - Response caching to control costs
- Streaming implementation for real-time UX
- A configuration UI for administrators
nr-llm handles all of this. Your extension focuses on what to ask the AI, not how to reach it.
If your extension keeps its own provider calls, it can still respect the editors' AI budgets and report its costs to the Analytics module: see Report usage from your own provider calls.
Step 1: Add the dependency
composer require netresearch/nr-llm
Add the dependency to your ext_:
'constraints' => [
'depends' => [
'typo3' => '13.4.0-14.99.99',
'nr_llm' => '0.4.0-0.99.99',
],
],
Step 2: Inject the service
All nr-llm services are available via TYPO3's dependency injection. Pick the service that matches your use case:
<?php
declare(strict_types=1);
namespace MyVendor\MyExtension\Service;
use Netresearch\NrLlm\Service\LlmServiceManagerInterface;
final readonly class MyAiService
{
public function __construct(
private LlmServiceManagerInterface $llm,
) {}
public function summarize(string $text): string
{
$response = $this->llm->complete(
"Summarize the following text in 2-3 sentences:\n\n" . $text,
);
return $response->content;
}
}
No Services. configuration needed — TYPO3's autowiring handles it.
Step 3: Use feature services for specialized tasks
For common AI tasks, use the specialized feature services instead of raw chat:
use Netresearch\NrLlm\Service\Feature\TranslationService;
final readonly class ContentTranslator
{
public function __construct(
private TranslationService $translator,
) {}
public function translateToGerman(string $text): string
{
$result = $this->translator->translate($text, 'de');
return $result->translation;
}
}
use Netresearch\NrLlm\Service\Feature\VisionService;
final readonly class ImageMetadataGenerator
{
public function __construct(
private VisionService $vision,
) {}
public function generateAltText(string $imageUrl): string
{
return $this->vision->generateAltText($imageUrl);
}
}
use Netresearch\NrLlm\Service\Feature\EmbeddingService;
final readonly class ContentRecommender
{
public function __construct(
private EmbeddingService $embeddings,
) {}
/**
* @param list<array{id: int, text: string, vector: list<float>}> $candidates
* @return list<int> Top 5 most similar content IDs
*/
public function findSimilar(string $query, array $candidates): array
{
$queryVector = $this->embeddings->embed($query);
$results = $this->embeddings->findMostSimilar(
$queryVector,
array_column($candidates, 'vector'),
topK: 5,
);
return array_map(
fn(int $index) => $candidates[$index]['id'],
array_keys($results),
);
}
}
Step 4: Handle errors gracefully
nr-llm throws typed exceptions so you can provide meaningful feedback:
use Netresearch\NrLlm\Provider\Exception\ProviderConfigurationException;
use Netresearch\NrLlm\Provider\Exception\ProviderConnectionException;
use Netresearch\NrLlm\Provider\Exception\ProviderResponseException;
try {
$response = $this->llm->complete($prompt);
} catch (ProviderConfigurationException) {
// No provider configured — guide the admin
return 'AI features require LLM configuration. '
. 'An administrator can set this up in AI > Setup.';
} catch (ProviderConnectionException) {
// Network issue — suggest retry
return 'Could not reach the AI provider. Please try again.';
} catch (ProviderResponseException $e) {
// Provider returned an error (rate limit, invalid input, etc.)
$this->logger->warning('LLM provider error', ['exception' => $e]);
return 'The AI service returned an error. Please try again later.';
}
Step 5: Use database configurations (optional)
For advanced use cases, reference named configurations that admins create in the
backend module. Resolve them through
Netresearch
— it works in user-less contexts (CLI, Symfony Messenger consumers,
anonymous frontend requests) and applies the guards a raw repository
lookup skips (ADR-070):
use Netresearch\NrLlm\Service\ConfigurationResolver;
use Netresearch\NrLlm\Service\LlmServiceManagerInterface;
final readonly class BlogSummarizer
{
public function __construct(
private ConfigurationResolver $configurationResolver,
private LlmServiceManagerInterface $llm,
) {}
public function summarize(string $article): string
{
// Uses the "blog-summarizer" configuration created by the admin
// (specific model, temperature, system prompt, etc.)
$config = $this->configurationResolver->getActiveByIdentifier('blog-summarizer');
$response = $this->llm->chat(
[['role' => 'user', 'content' => "Summarize:\n\n" . $article]],
$config->toChatOptions(),
);
return $response->content;
}
}
The method throws typed exceptions (all implementing
Nr): Configuration
when no record with the identifier exists,
Configuration when it exists but is deactivated,
and Access when it is restricted to backend groups
(user-less callers cannot prove group membership; see
ADR-070).
Warning
Do not resolve configurations via
Llm directly: it
ignores the record's isActive flag and its backend-group access
restrictions, so a deactivated or restricted configuration would keep
serving your calls.
Step 6: Declare the configurations you need (optional)
Instead of documenting "please create a configuration named
myext." in prose, declare it as a configuration preset: implement
Configuration (auto-tagged
nr_) and express your requirements as
Model — never a concrete provider, model, or API
key. nr_llm lists your presets as pending and a backend admin imports
them with one confirmation as criteria-mode configuration records.
See Configuration presets for the declaration example and import flow.
Testing your integration
Mock the nr-llm interfaces in your unit tests:
use Netresearch\NrLlm\Domain\Model\CompletionResponse;
use Netresearch\NrLlm\Domain\Model\UsageStatistics;
use Netresearch\NrLlm\Service\LlmServiceManagerInterface;
use PHPUnit\Framework\TestCase;
final class MyAiServiceTest extends TestCase
{
public function testSummarizeReturnsCompletionContent(): void
{
$llm = $this->createStub(LlmServiceManagerInterface::class);
$llm->method('complete')->willReturn(
new CompletionResponse(
content: 'A short summary.',
model: 'gpt-5.3-instant',
usage: new UsageStatistics(50, 20, 70),
finishReason: 'stop',
provider: 'openai',
),
);
$service = new MyAiService($llm);
self::assertSame('A short summary.', $service->summarize('Long text...'));
}
}
Integration checklist
- composer.json — Added
netresearch/tonr- llm require - ext_emconf.php — Added
nr_tollm dependsconstraints - Services — Inject
Llmor feature services via DIService Manager Interface - Error handling — Catch typed exceptions and show user-friendly messages
- Testing — Mock
Llmin unit testsService Manager Interface - Documentation — Tell your users they need to configure a provider in AI > Setup