A language-learning app for translation practice with AI grading. I build and run my own products, carrying them through releases, incidents and support.
FOCUS
- Language learning
- Mobile
- AI grading
FULL-STACK ENGINEER · REGENSBURG, GERMANY
I’m Isaac Hatilima, a full-stack and DevOps engineer. More than six years of shipping web and mobile products, from the database to the App Store, and building the infrastructure underneath them.
THE RACK
A language-learning app for translation practice with AI grading. I build and run my own products, carrying them through releases, incidents and support.
FOCUS
At Bet3000 Entertainment, I built a real-time device management system with Livewire, Laravel, MySQL and WebSockets for concurrent live updates across retail locations. It cut operational process time by 80%.
STACK
At Bet3000 Entertainment, I built a multi-source reporting layer with Blade and Laravel. It used a MySQL application database and a separate MySQL reporting source alongside MSSQL and Oracle, improving BI accessibility by 60%.
STACK
At Bet3000 Entertainment, I built a franchise management platform with React, Laravel and PostgreSQL, implementing complex multi-region business logic and scalable interfaces for high data volumes.
STACK
At APS-Tech Group, I worked on a real-time IoT backend in Python and Django REST Framework, a React Native app for point-of-interest exploration, and a WPF industrial metrology application.
STACK
At TechMasters Zambia, I built a PHP incident tracking system used by more than 100 staff across several banks. I also led an enterprise Windows migration and supported the ATM network.
STACK
Kubernetes (k3s) clusters with Traefik and Longhorn, Hetzner infrastructure provisioned with Terraform and Ansible, observability with Grafana and Prometheus, and high-availability PostgreSQL.
STACK
UPTIME LOG
Kubernetes (k3s) clusters with Traefik and Longhorn, Hetzner infrastructure provisioned with Terraform and Ansible, observability with Grafana and Prometheus, high-availability PostgreSQL.
Production web applications in React, TypeScript and PHP. Built a real-time device management platform that cut operational process time by 80%, a franchise management application and a reporting layer across MSSQL and Oracle.
A React Native app for point-of-interest exploration, a real-time IoT backend in Python and Django REST Framework, and a WPF industrial metrology application.
Built a PHP incident tracking system used by 100+ staff across several banks, led an enterprise Windows migration and kept ATM network uptime above 95%.
Evelyn Hone College, Lusaka, Zambia
TOOLS
React, TypeScript, JavaScript, Inertia.js, Livewire, Tailwind CSS
React Native, Expo, App Store and Google Play releases
PHP 8, Laravel (Horizon, Reverb, Cashier), Python, Django REST Framework, Node.js
REST, OpenAPI, WebSockets, OAuth 2.0, JWT, webhooks
PostgreSQL, MySQL, Redis, MSSQL, Oracle
Docker, Kubernetes (k3s), Terraform, Ansible, Traefik, Hetzner Cloud, Grafana, Prometheus
GitLab CI/CD, GitHub Actions, Jenkins, Pest, Vitest
English (native), German (A1, improving)
ALSO OVER MCP FOR CODING AGENTS
Problem. Put a heavy job behind an "Export orders" button and every click queues another copy. Impatient users click two, three, ten times, and each click is another full export competing for the same workers and the same database.
Guarding the dispatch with a cache flag looks like a fix, but Cache::has() followed by Cache::put() is two separate steps, so two quick requests can both get past the check. The flag also outlives the job: it stays set after the export has finished, so the button does nothing until the flag expires.
Fix. Implement ShouldBeUnique on the job and return the key that defines a duplicate from uniqueId(). When the job is dispatched, Laravel takes an atomic cache lock named after the job class and that key. If the lock is already held, the new dispatch is dropped. The lock is released as soon as the job finishes, or once it has failed all of its retries.
The #[UniqueFor] attribute is the safety net. It releases the lock after that many seconds even if a worker dies halfway through, so one crash cannot block exports for good.
Result. Extra clicks become no-ops instead of extra exports, and there is no flag to clear or forget. The lock lives in the cache, so use a store that supports atomic locks (Redis, Memcached, DynamoDB, database, file or array) and make sure every server shares it. If you only want to stop two copies running at once but still queue the second one, use the WithoutOverlapping job middleware instead.
// Before: a flag that two quick clicks can both get pastif (! Cache::has("export:{$team->id}")) { Cache::put("export:{$team->id}", true, 900); ExportOrders::dispatch($team);}// After: the queue holds the lock#[UniqueFor(900)]class ExportOrders implements ShouldQueue, ShouldBeUnique{ use Queueable; public function __construct(public Team $team) {} // One export per team; other teams still run in parallel public function uniqueId(): string { return (string) $this->team->id; } public function handle(): void { // Build and email the CSV... }}// Ignored while this team's export is queued or runningExportOrders::dispatch($team);Problem. number_format() is made for display. With its defaults it puts a comma between the thousands, so number_format(12499.9, 2) returns the string "12,499.90".
Cast that string back with (float) and PHP reads it from the left until it meets a character that cannot be part of a number. The comma stops it, and 12,499.90 becomes 12.0. There is no error and no warning, just a wrong total in the invoice, the CSV export or the payment request.
Fix. When a machine will read the output, pass the decimal point and an empty thousands separator yourself: number_format($total, 2, '.', '') returns "12499.90", which casts back to the value you started with.
Better still, do all the maths on the original number and call number_format() once, at the very end, for whoever reads the result.
Result. Totals stay correct, and each output has the format its reader expects: separators for people, none for CSV files, APIs and form values. For money, store whole cents as integers so floating-point rounding cannot creep into totals, and keep number_format() for display.
// Before: formatted for people, then used as a number$total = number_format(12499.9, 2); // "12,499.90"$withVat = (float) $total * 1.16; // 13.92, because (float) read 12.0// After: do the maths on numbers, format only for output$withVat = 12499.9 * 1.16; // 14499.884$forPeople = number_format($withVat, 2); // "14,499.88"$forTheApi = number_format($withVat, 2, '.', ''); // "14499.88"Problem. DTOs keep data typed inside the app, but every endpoint that returns them has to turn them back into arrays first. The same map() with the same keys gets copied into each controller, and the copies drift apart: one endpoint sends price, another sends price_in_cents.
Fix. Implement Illuminate\Contracts\Support\Arrayable and give the DTO a toArray() method. Laravel looks for this contract wherever it turns data into output: a collection's toArray() and its JSON encoding convert every Arrayable item, and a controller can return a DTO, or a collection of them, to get a JSON response.
Result. The response shape lives in one place, next to the data it describes, and every endpoint stays in step. Arrayable is a generic interface, so the @implements line tells PHPStan which keys and values to expect. If you also call json_encode() on a DTO yourself, implement JsonSerializable as well: plain json_encode() ignores toArray(), while Laravel's collections and responses understand Arrayable on its own.
// Before: the same mapping in every controllerreturn response()->json( collect($quotes)->map(fn (ShippingQuote $quote) => [ 'carrier' => $quote->carrier, 'price' => $quote->priceInCents, 'days' => $quote->deliveryDays, ]));// After: the DTO owns its array shape/** * @implements Arrayable<string, string|int> */final readonly class ShippingQuote implements Arrayable{ public function __construct( public string $carrier, public int $priceInCents, public int $deliveryDays, ) {} public function toArray(): array { return [ 'carrier' => $this->carrier, 'price' => $this->priceInCents, 'days' => $this->deliveryDays, ]; }}// Each quote is converted for youreturn collect($quotes);Problem. A loop that touches a relation fires one query per row. It feels fine with ten rows in development and stalls with ten thousand in production.
Fix. Turn lazy loading into an error everywhere except production, so the slow page fails loudly while you are still building it.
Result. The exception names the exact relation to eager load. One line, and the whole team gets the check.
// app/Providers/AppServiceProvider.phppublic function boot(): void{ Model::preventLazyLoading(! app()->isProduction());}BLOG · LATEST POSTS
Coding finds are quick notes. The blog is where I take a decision or a project apart in full.

Engineering7 min read
A practical look at when a basic folder structure starts to hurt, how I organize features by domain, and why I built domain-driver to scaffold the repetitive parts.
Read the postEngineering4 min read
NIST's password guidance is a useful prompt to revisit complexity rules, password expiry, and the legacy systems that still enforce them.
Engineering5 min read
This guide explains the file structure of a Laravel application that I use and makes sense.
I’m based in Regensburg, Germany. Get in touch on LinkedIn, explore my code on GitHub, or download my CV for the full picture.