From Monolith to Microservice – An OCR Service Case Study
Why Do This?
You’re developing a new optical character recognition (OCR) feature — extracting text from photos. This functionality is an excellent candidate to be implemented as a supporting microservice for your main system. Thanks to this, you can scale it independently, both horizontally (by running additional instances) and vertically (by increasing resources on a single instance) according to current needs and load.
Instead of building the entire microservice infrastructure from scratch right away, you begin by creating it as another module inside your current monolith. Only then do you gradually extract it into a standalone microservice. This significantly simplifies and accelerates the whole process.
In this article, I walk you through the individual steps of this approach.
October 6, 2026
The starting point
The project is nowreading.club, a Laravel application built as a modular monolith with DDD. It has two kinds of modules:
organizing modules in
src/*: namespaces with Domain / Application / Infrastructure layers that may import each other;strictly isolated modules in
src_iso/*: future microservices. Nothing outside may import their classes. The rest of the app talks to them only through contracts insrc/Shared: a Query object, dispatched on Laravel's Bus, handled by a Handler inside the module.
An isolated module (src_iso/Bits) and a Bus adapter for it (BusBitReader) already existed. OCR was going to be the second isolated module and the first one to actually leave the monolith.
A note before we start: the whole solution, process described here was done by hand, really, without the help of any AI models back then! Today is October 6, 2026, and Claude is helping me reconstruct the events from the git history.
The map
The extraction took about two weeks. In every step the app kept working, and in every step one config value could switch back to the previous one.
| Step | What changes | What it proves | When |
|---|---|---|---|
| 1. A seam with a stub | Interface + Bus adapter + empty HTTP adapter | Callers do not know where OCR lives | 2026-04-15 |
| 2. Real recognition, in-process | Tesseract, async job, signed webhook | The contract survives real, asynchronous work | 2026-04-17…19 |
| 3. HTTP, same process | The HTTP adapter calls the same app | Serialization, signing, timeouts, error paths | 2026-04-19 |
| 4. A second container | Same image, APP_ROLE=ocr | What breaks once there is a real network in between | 2026-04-25 |
| 5. A dedicated entry point | public/ocr.php, own bootstrap and provider list | The service boots only OCR | 2026-04-25 |
| 6. Own schema and migrations | ocr connection, per-role migrations | The service owns its data | 2026-04-26 |
| 7. Two production servers | Laravel Cloud + Laravel Forge | It runs in production | 2026-04-28…30 |
Step 1: Build the seam before the service
The first commit had no OCR in it at all. It had:
src_iso/Ocrwith empty Domain / Application / Infrastructure folders;a handler that returned a fixed string ("sample recognised text");
a contract in
src/Shared: an interface and two implementations,BusOcrQuery(in-process) andDistributedBusOcrQuery(remote). The second one was an empty class;a console command that called the interface and printed the result.
That empty DistributedBusOcrQuery was the most important file of the whole project. It put the place where the network will go into the code on day one, before there was any network to deal with. Every later step just filled it in.
This is the contract as it looks today. It is still small:
interface OcrQueryInterface
{
public const string RECOGNIZE_PATH = '/api/██████████';
public function recognizeText(string $ocrId, string $imagePath, string $webhookUrl): OcrAccepted;
}
The in-process adapter is a single Bus dispatch:
class BusOcrQuery implements OcrQueryInterface
{
public function recognizeText(string $ocrId, string $imagePath, string $webhookUrl): OcrAccepted
{
/** @var OcrAccepted $ocrAccepted */
$ocrAccepted = Bus::dispatch(new RecognizeTextQuery($ocrId, $imagePath, $webhookUrl));
return $ocrAccepted;
}
}
Step 2: Make it real, and make it asynchronous
Two modules, not one
OCR ended up as two modules with two different jobs:
src/Ocr (main app) | src_iso/Ocr (the future service) | |
|---|---|---|
| Role | The app's record of each request | Does the work: load, validate, recognise, report |
| Data | ocrs table: status stored → reading → read / failed, text, error | Its own insert-only ocr_requests table, one row per finished attempt |
| Shape | No aggregate on purpose: it is a record, not a business concept | A Domain made of ports: image loader, format validator, text recognizer, webhook client |
Neither module calls the other. The app glues them together. The recognition module keeps its own table so it never has to reach back into the main app's database. Once it runs on a different server, it would not be able to anyway.
One round trip
main app OCR module / service
──────── ────────────────────
ocrs row (ULID) ──► OcrQueryInterface ──bus/HTTP──► RecognizeTextQueryHandler
│ image exists? → OcrAccepted (sync)
◄────────── OcrAccepted(success: true|false) ───┘
mark READING / FAILED │ queue RecognizeTextJob
▼
load image from the shared bucket
validate format → Tesseract
POST /webhooks/ocr ◄──────── signed webhook ───── send result, save attempt
mark READ / FAILED
A few decisions in this step made every later step easier:
Pass a reference, not bytes. The main app uploads the image to object storage (MinIO locally, S3 in production) and sends only its path. Both sides point at the same bucket.
Answer fast, report later. The handler only checks that the image exists. It returns an acknowledgement right away and queues the real work. The result comes back by webhook.
One correlation key. Requests were first matched by image path, then by a UUID, and finally by the ULID of the
ocrsrow.The caller sends the webhook URL. The service does not need to know where the main app lives.
The plan I did not build
The first design for the remote part was much bigger: SQS between the apps, a separate Laravel consumer on Forge reading raw SQS messages, a signed webhook back, and Reverb WebSockets to push the result to the browser. It came with a list of ten open questions: per-user vs per-job channels, presigned URLs, idempotency, retention, UI timeouts, and so on.
What survived from that plan: asynchronous processing, the signed webhook, and the shared object storage. What did not: SQS, Reverb, and a separate application. The remote call is a plain signed HTTP request, and the queue is Laravel's own (Redis + Horizon) on the OCR side.
Why drop them? For the sake of simplicity in the first version. The design can grow later, but only when there is a real need for it. For now, what is there is enough.
Choosing the engine
I also looked at the Laravel AI SDK with a vision model and at AWS Textract:
| Tesseract | Vision LLM via Laravel AI SDK | AWS Textract | |
|---|---|---|---|
| Cost per image | ~0 (worker CPU) | $0.001–0.02 | $0.0015–0.05 |
| Handwriting, tables | weak | good | very good |
| Latency | 0.2–2 s | 2–10 s | 1–5 s |
| Text leaves your infrastructure | no | yes (unless self-hosted) | yes |
Tesseract won for now (Polish + English language data). Behind the TextRecognizerInterface port, the engine is an Infrastructure detail. Switching to a vision model later changes one adapter, not the architecture.
Step 3: Go over HTTP without leaving the process
Before adding a second server, I added a network hop inside a single one. DistributedBusOcrQuery got a real implementation, and OCR_SERVICE_URL pointed at the application itself.
It sounds pointless, but it tests everything about the remote call that does not need real infrastructure: JSON serialization, request signing, timeouts, error handling, and switching the binding through config. Debugging those things with a single process is much easier than with two servers.
The OCR module exposes POST /api/██████████. Its controller dispatches the same RecognizeTextQuery on its own Bus, so both transports end up in one handler:
class RecognizeTextController
{
public function __invoke(RecognizeTextRequest $request): JsonResponse
{
/** @var array{ocrId: string, imagePath: string, webhookUrl: string} $data */
$data = $request->validated();
/** @var OcrAccepted $accepted */
$accepted = Bus::dispatch(new RecognizeTextQuery(
ocrId: $data['ocrId'],
imagePath: $data['imagePath'],
webhookUrl: $data['webhookUrl'],
));
return response()->json([
'imagePath' => $accepted->imagePath,
'success' => $accepted->success,
'errorMessage' => $accepted->errorMessage,
]);
}
}
The caller picks the transport with one config value:
$this->app->bind(OcrQueryInterface::class, function (Application $app): OcrQueryInterface {
if (config('ocr.driver') === 'distributed') {
return new DistributedBusOcrQuery(/* http client, signature, url, secret, timeout */);
}
return $app->make(BusOcrQuery::class);
});
Two design rules from this step:
A domain failure is still a successful HTTP call. "Image not found" returns
200withsuccess: false. Non-2xx codes are only for transport, authentication and server errors.The HTTP adapter turns network failures into domain answers. A timeout, a non-2xx response or a malformed body becomes
OcrAccepted(success: false, errorMessage: …). The caller has one code path, whether OCR runs in-process or two servers away.
Signing both directions with one class
Requests to the service and webhooks back use the same signature class:
class OcrWebhookSignature
{
public function sign(string $body, string $secret): string
{
// ...
}
public function verify(string $header, string $body, string $secret, int $tolerance): bool
{
// ...
}
}
The signature is an HMAC-SHA256 that covers both the body and a timestamp. It is compared in constant time, and signatures that are too old are rejected.
Step 4: A second container — let the network bite locally
Step 3 was still one process, one database and one queue. It proved nothing about real isolation. Step 4 added an ocr service to docker-compose.yml. It uses the same image and the same code, but has its own role and its own Horizon:
ocr:
image: channels-8.5/app
environment:
APP_ROLE: ocr
OCR_DRIVER: bus # never call yourself over HTTP
REDIS_DB: 2
REDIS_CACHE_DB: 3
HORIZON_PREFIX: 'channels_ocr_horizon:'
HORIZON_NAME: ocr
HORIZON_SUPERVISORS: supervisor-ocr
It found exactly the kind of problems that would have been painful to debug on a remote server:
The webhook URL was unreachable. The callback URL was built from
APP_URL, a host-side domain that the OCR container could not resolve. The fix was a separateOCR_WEBHOOK_BASE_URL.The service could call itself. The OCR container inherited
OCR_DRIVER=distributedfrom.env. Any code path that resolved the interface would have sent an HTTP request back to the same container. The compose file now forcesbusthere.Queues and Horizon dashboards mixed up. Each container got its own Redis databases, Horizon prefix and name, and a
HORIZON_SUPERVISORSlist. That list chooses which supervisors start on which machine.A dev-server detour. Inside the container,
artisan servedid not pass every environment variable to its request workers, so the Horizon dashboard showed Inactive. Runningphp -Swithpublic/index.phpas the router broke static assets, because Laravel'sindex.phpnever hands a file back to the built-in server. What worked wasphp -Swith the router Laravel ships forartisan serve(Illuminate/Foundation/resources/server.php).
Step 5: How much of the monolith should the OCR server boot?
The same code now ran in a container meant only for OCR, but it still booted Filament, Livewire and the public site. There are three levels of "turning the rest off":
| Level | What is in memory on the OCR server | Risk |
|---|---|---|
| 1. Fence middleware: 404 for everything outside a whitelist | The whole app | Code is unreachable, but still loaded |
2. Selective providers: skip providers when APP_ROLE=ocr | Only OCR-related providers | Two lists to keep in sync; easy to miss a listener |
| 3. A dedicated entry point | Only what OCR's own bootstrap registers | Some duplicated bootstrap code |
I implemented level 2 first and replaced it with level 3 shortly after. The problem with level 2 is that it is runtime branching that fails open. If someone removes the APP_ROLE variable or forgets a branch, the OCR server quietly boots Filament again. Level 3 makes the separation structural: the OCR server has its own front controller, public/ocr.php, which loads its own bootstrap:
// bootstrap/ocr.php
return Application::configure(basePath: dirname(__DIR__))
->withProviders(require __DIR__ . '/providers-ocr.php', withBootstrapProviders: false)
->withRouting(
commands: __DIR__ . '/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->append(SetLocale::class);
})
->withExceptions(function (Exceptions $exceptions): void {})->create();
// bootstrap/providers-ocr.php
return [
App\Providers\HorizonServiceProvider::class,
Laravel\Nightwatch\NightwatchServiceProvider::class,
OcrRecognition\Infrastructure\OcrProvider::class,
];
It has no web routes (only the /up health check) and no main-app providers or middleware, so there is nothing left to gate. One branch remains: artisan is shared, so it picks the bootstrap by role.
$bootstrap = getenv('APP_ROLE') === 'ocr' ? '/bootstrap/ocr.php' : '/bootstrap/app.php';
The duplicated bootstrap is the whole point. A change to the main app's bootstrap cannot leak into the service. On the day OCR moves to its own repository, these files move over 1:1.
Step 6: Data — one database, two schemas
The service had to own its data without needing a new database server on day one. The compromise: the same PostgreSQL instance and database, but an ocr schema reached through a separate ocr connection (search_path=ocr).
return new class extends Migration
{
protected $connection = 'ocr';
public function up(): void
{
DB::statement('CREATE SCHEMA IF NOT EXISTS ocr');
Schema::create('ocr_requests', static function (Blueprint $table): void {
// ...
});
}
};
The rules that make this safe:
Each server runs only its own migrations. OCR migrations are not auto-discovered outside tests. The OCR server runs them explicitly:
php artisan migrate --database=ocr --path=src_iso/Ocr/Infrastructure/Persistence/migrations. Their history lands inocr.migrations, so the two servers never race on onemigrationstable.Create the schema idempotently, and never
DROP SCHEMA … CASCADE. On a shared instance it may already hold data.The default connection stays
pgsqlon both servers. Otherwise everything that uses the default connection would run against the wrong schema.Tests need their own refresh trait.
RefreshDatabaseonly refreshes the default schema.
Moving OCR to its own database later only means giving the ocr connection its own host variables. The module code and the migrations stay as they are.
Redis is shared too, but only for convenience of having one instance: main ↔ OCR traffic goes over HTTP and never depends on it. Object storage, on the other hand, must be shared, because the main app uploads and the service reads.
Step 7: Production — Laravel Cloud + Laravel Forge
The main app runs on Laravel Cloud (Octane on FrankenPHP). The OCR service runs on a Forge server that provides the Tesseract binary with its language data. By this point, production was mostly infrastructure work, not code. The rollout order kept every step reversible:
Keep production on
OCR_DRIVER=bus.Provision the OCR server and deploy the same repository with
APP_ROLE=ocr. Nobody calls it yet.Check it from the outside:
/upreturns 200,/returns 404, and an unsignedPOST /api/██████████returns 401.Switch the main app to
OCR_DRIVER=distributedand pointOCR_SERVICE_URLat the service.Stop the main app's
supervisor-ocr(HORIZON_SUPERVISORS=supervisor-1), so it no longer takes jobs from its old queue.Watch it for a while.
The OCR deploy skips everything that exists only for the main app's front end:
git pull
composer install --no-dev --no-scripts --optimize-autoloader --no-interaction
php artisan package:discover --ansi
php artisan horizon:publish
php artisan migrate --force --database=ocr --path=src_iso/Ocr/Infrastructure/Persistence/migrations
php artisan optimize
php artisan horizon:terminate
--no-scripts skips the Filament and Livewire asset hooks, and there is no npm run build, because the service never renders a Vite asset.
The route-cache trap
The first production deploy turned the OCR service back into the full web app. Opening / on the OCR host returned a 500 instead of a 404. The stack trace started in public/ocr.php but ended in the main app's HomeController, querying a channels table that does not exist in the OCR schema.
The cause was Forge's default deploy script. artisan route:cache compiled the main app's routes into bootstrap/cache/routes-v7.php, and that directory is shared by both entry points. Laravel loads a route cache file whenever one exists, so the OCR bootstrap served routes it never registered.
The fix: never run route:cache or view:cache on the OCR server (config and event caches are safe), then clear the bad cache once. Caching routes for an app with a single route gives you nothing anyway.
The last move: the caller picks the transport
On 2026-04-30 the interface binding moved out of the OCR module into the main app. It was a small commit, but it settled who owns what: the isolated module provides the handler, and the calling side decides how to reach it. The service no longer knows that OcrQueryInterface exists.
Lessons
Build the seam first, while it is free. An empty
DistributedBusOcrQueryon day one cost nothing. Every later step filled it in.Keep every step reversible with one config value.
OCR_DRIVER=busstill works today.Add the network in layers. Same process over HTTP, then a second container, then a second server. Each layer finds a different class of bugs, and the cheap layers find them first.
Make separation structural, not conditional. A separate entry point cannot be forgotten. An
if (APP_ROLE)can.Send references, not payloads. A path in a shared bucket plus a webhook keeps both sides simple.
Contracts must tell the truth.
OcrDatawith empty text becameOcrAccepted.Own your data from the start, even on a shared server. A separate schema, connection and migration history make the later move to a separate database a config change.
What's next
That is a f*cking good question! So many things has changed in programming industry that I still wonder what the next step should be for me... I've started to learn welding, really.
probably a mobile app