TL;DR:
I receive electronic invoices (bills) through Billit and pull them into my custom business software using their API. Although the bills get a category assigned on the platform, Billit doesn't send that through the API.
I got tired of categorising the bills manually, so I let an LLM pick the right category from a list of options.
I decided against a rules-based approach in favour of AI because it takes a lot of effort to set up and still gets it wrong when a supplier sells different things. Using Symfony's AI bundle, I connected to Google Gemini 2.5 Flash's free-tier API and got fantastic results with only a single service and a few lines of config.
Receiving electronic invoices
As I am a freelance software developer operating under a company structure in Belgium, I am obligated to use the PEPPOL
network for sending and receiving electronic invoices since the 1st of January 2026.
To get those invoices to and from the PEPPOL network, you need to use a so-called 'PEPPOL gateway'. As I already had
developed my own invoicing/business software and wanted to continue using it, I briefly thought about becoming a PEPPOL
gateway myself. After some research I decided against it, because the effort and costs involved didn't seem worth
it compared to simply integrating one.
I chose Billit as my PEPPOL gateway because they include API-access with their subscription and are the most
cost-effective solution for my situation. I also liked that their API looked very easy to implement and had great
documentation!
Billit is also business software itself, tailored to freelancers and small businesses. This gives me the added benefit
of being able to use it as a backup of sorts.
Missing category field
After my choice was made, I started integrating Billit so my own invoices were pushed to their platform to be able to
send them through PEPPOL. This went smoothly and next on the list was pulling in invoices sent to me via PEPPOL.
As Billit is my gateway to the PEPPOL network, you could see them as my 'inbox' where the bills first appear, analogous
to email. I would then 'check my inbox' and pull in the bills to my own software.
I refer to these incoming invoices as 'bills' in my software and the rest of this post, to keep the distinction with my outgoing invoices clearer.
While implementing the API endpoint to pull in bills, I noticed the returned objects didn't carry a category field, even
though I had assigned them one in the platform. After some more searching, testing and experimenting with a few things, I
accepted that I wouldn't be able to receive a category from Billit's API.
After implementing a few other endpoints (clients, suppliers, PDFs, ...), I called the integration complete and was
ready for electronic invoicing.
Automating the category assignment
After a few months of the integration running mostly as intended, I got fed up with the tedious process of manually assigning categories to the bills. I wanted to automate the process and started thinking about how I could best achieve this.
The first thing I thought of is what I'd seen on Billit's platform and various budgeting apps, which I'll call "rules".
The level of automation and customisation used varies between apps (e.g.: some apps do this behind the scenes and don't
allow you to control it), but the concept is the same everywhere: a "rule" defines which category to assign based on a
combination of parameters, most likely the bill's sender (a.k.a. supplier) and the amount. Other parameters such as the
date and/or a description could be used too.
Overall, this works pretty well as proven by those various apps, but lacks flexibility because of the limited amount of
data they can look at. Various types of purchases from the same supplier often get assigned wrongly to the same category.
Setting up the rules would also take a non-trivial amount of effort upfront before reaching an acceptable level of automation. Combined with knowing that I was almost guaranteed to still get wrongly assigned categories, which are also harder to spot than no assigned category at all, I kept looking for a better way.
The next thing I wanted to explore was AI. A first test in a chat window, supplying a model with a list of categories
and a few test cases provided an almost 100% success rate from a (manual, one-line) description, the supplier and the
amount. The results with only the supplier and the amount were less spectacular, as only 50% of the categories came out
right.
Then of course, I could still go a step further, beyond what rules would ever be able to do*: let the AI read the actual
bill. I ran my test again, this time also providing the PDF files, and every assigned category was spot on for my small
amount of test cases. With such promising results, I went ahead with implementing AI to do the category assignment.
* As it turns out, in the case of e-invoicing, you actually could. E-invoices are sent in an XML format (the PDF is just a bonus), which is easily parsed.
However, parsing the invoices and getting it matching properly means creating an even more complex setup that needs a lot of tuning and is a pain to maintain. In addition, building my first actual AI feature is valuable experience I will definitely be needing in the future.
Setting up the infrastructure
In summer 2025, Symfony released a suite of AI components to easily build AI-powered features. As my custom business
software is built with Symfony, this is obviously the way to go. At SymfonyCon 2025, I also attended the 'Symfony AI
in Action' session, which provided a very nice introduction to the components and showed several use cases, so I already
had a good idea of how to get started.
I installed symfony/ai-bundle and symfony/ai-gemini-platform, as I would be using Google's Gemini API.
I chose Google and its Gemini 2.5 Flash model because it's the most capable one fits my use case on it's free tier.
The entire setup is underwhelmingly simple. You point the platform of choice to your API key in .env, register an agent and point it at the platform and then give it the name of the model to use and possibly some options:
ai:
platform:
gemini:
api_key: '%env(GEMINI_API_KEY)%'
agent:
bill_category_classifier:
platform: 'ai.platform.gemini'
model:
name: 'gemini-2.5-flash'
options:
temperature: 0.1
The actual code required to implement this is equally unimpressive, because it's a simple feature. I set up a
service with a main function, build the system prompt which tells the agent what it's going to do and what the options
are and then the user prompt which is just the textual bill data. The bill's PDF file is also added into the user prompt.
The prompts are put into the specific AI component's SystemMessage and UserMessage objects and wrapped in the
MessageBag object before it is sent to the agent.
This gets called from a command handler when a new bill is pulled in from Billit and looks something like this (some boilerplate omitted):
use Symfony\AI\Agent\AgentInterface;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\AI\Platform\Message\SystemMessage;
use Symfony\AI\Platform\Message\UserMessage;
class BillCategoryGuesserService
{
public function __construct(
private readonly AgentInterface $billCategoryClassifierAgent,
private readonly CategoryRepository $categoryRepository,
private readonly StorageInterface $storage,
private readonly LoggerInterface $logger,
) {
}
public function guessCategory(Bill $bill): ?Category
{
$categories = $this->categoryRepository->findAllChildren();
// Error checking and extracting name and description
...
// Construct prompts
$systemPrompt = $this->buildSystemPrompt($categoryContext);
$userPrompt = $this->buildUserPrompt($bill);
// Logging
...
$userContent = $this->buildUserContent($bill, $userPrompt); // Add the bill PDF file to the request
$messages = new MessageBag(
new SystemMessage($systemPrompt),
new UserMessage(...$userContent)
);
$result = $this->billCategoryClassifierAgent->call($messages);
// Error checking and parsing result into category name
...
return $this->findCategoryByName($categories, $response);
}
private function buildSystemPrompt(array $categoryContext): string
{
$categoriesList = implode("\n", array_map(
static fn (array $category): string => "| {$category['name']} - {$category['description']}",
$categoryContext
));
return "You are a bill categorization assistant. Your task is to classify bills into the most appropriate category based on the supplier name, bill description, and any attached invoice document.
Available categories:
{$categoriesList}
Instructions:
- Analyze the supplier name, description, and attached document (if provided) to determine the most fitting category
- Return ONLY the exact category name from the list above
- If no category fits well, return exactly 'UNKNOWN'
- Do not add any explanation or additional text";
}
private function buildUserPrompt(Bill $bill): string
{
$supplierName = $bill->getSupplier()?->getName() ?? 'Unknown';
$description = $bill->getDescription() ?? '';
$amount = $bill->getAmount() ?? 0;
return "Supplier: {$supplierName}
Description: {$description}
Amount: {$amount}";
}
// More private helper functions
...
}
Results
With an AI-powered feature, where the proper functioning is mostly dependent on the prompt, I expected to have to do some tuning. However, the prompts shown earlier are the first versions and are still in use, as they produce almost perfect results.
I do have to nudge an assignment occasionally because of missing context, even though it's a good pick on its own.
E.g.: the invoice from my fuel card provider being assigned to fuel costs for a car, although I only ride a motorcycle.
I could add that context into the system prompt, but that risks an overgrown mess of a prompt over time and a decline in
the overall result quality.
It's a small and quick feature, but it's my first AI-powered one, and I'm very happy and impressed with how well this works and how easy it was to implement.