Building PHP Ukraine with Laravel and PostgreSQL
Project Overview
A couple of weeks ago I started building PHP Ukraine. The initial idea was pretty simple. I wanted one place where PHP developers in Ukraine could find jobs, companies, interview questions, documentation and useful technical content. Then, as usual, the project started growing.
I added job aggregation from several sources, classification, deduplication, salary normalization, search, employer tools, analytics, content processing and a few other things. The current stack is PHP 8.4, Laravel 13, PostgreSQL 17, Livewire, Filament and Docker with FrankenPHP.
I want to show a few parts of the project that were interesting to build, including some decisions that changed while I was working on it.
Project Structure
I did not want everything to slowly end up inside one huge app/ folder. So most of the business code lives under src/. Right now there are separate contexts for Hiring, Content, ContentOps, Search, Analytics, Contact, Development and SEO. Most of them are split into Domain, Application and Infrastructure.
Laravel still handles the framework side of things. Controllers, Livewire components, console commands, middleware and service providers stay in app/. Ports are connected to infrastructure implementations through service providers.
I am not trying to turn the project into some kind of DDD showcase. I just wanted clear boundaries because I know how quickly a Laravel project can become a place where everything knows about everything.
I also added architecture tests with Pest. For example:
arch('domain layers stay framework-free')
->expect([
'PhpUkraine\Hiring\Domain',
'PhpUkraine\ContentOps\Domain',
'PhpUkraine\Seo\Domain',
])
->not
->toUse([
'Illuminate',
'Livewire',
'App',
]);
arch('the web layer never touches infrastructure directly')
->expect([
'App\Http',
'App\Livewire',
])
->not
->toUse([
'PhpUkraine\Hiring\Infrastructure',
'PhpUkraine\ContentOps\Infrastructure',
]);
That way the architecture is not just something written in a markdown file. The tests actually complain if I break the boundaries.
Job Aggregation
The jobs section currently imports vacancies from Robota.ua, DOU and Djinni. Each source implements the same JobSource interface and returns normalized job objects.
Robota.ua uses JSON API endpoints. DOU and Djinni are imported through RSS. Djinni is slightly different because the RSS feed does not contain enough information. For a new Djinni vacancy, the app can fetch the vacancy page and read the JobPosting JSON-LD to get things like employer, salary and expiration date.
The sync process is roughly:
- Fetch jobs from the source
- Ignore employers I do not want on the platform
- Ignore jobs that are clearly not PHP jobs
- Update an existing job if the source reference already exists
- Check whether the same job already exists from another source
- Fetch extra details if needed
- Create a new job
- Mark jobs that disappeared from the source as stale
The main sync runs every hour. Deduplication, expiration, salary exchange rates and market snapshots run separately. I prefer this over one huge command that tries to do everything. It also makes it much easier to understand what failed.
Classification Without AI
One thing I could have done was send every job through an LLM. I did not. The classifier is just rules. It tries to detect framework, seniority, city, work format, employment type, English level, salary and technology tags.
For example, salary parsing understands things like:
$3000-4000from $2500up to β¬400080k UAH3k USD
The original salary stays in its original currency, but I also normalize it to USD for filtering and sorting. Exchange rates come from the National Bank of Ukraine. For this particular task, normal rules are just easier. They are cheap, deterministic and easy to debug. AI is useful in other parts of the project, but I do not see a reason to use it where a parser does the job better.
Deduplication Across Job Boards
The same job can appear on DOU, Djinni and another source at the same time. I did not want users to see the same vacancy three times. So jobs get a fingerprint based mainly on the company name and title. The simplified version looks like this:
$company = PlainText::normalizeKey($companyName);
$company = preg_replace('/\b(llc|ltd|inc|ΡΠΎΠ²|ΡΠΎΠΏ|group|company)\b/u', '', $company) ?? $company;
$cleanTitle = str_ireplace(self::NOISE, ' ', $title);
$cleanTitle = PlainText::normalizeKey($cleanTitle);
return $company . '|' . $cleanTitle;
Stuff like (remote), urgent, legal company suffixes and similar noise is removed before comparison. If the same vacancy is found on another source, I keep one primary record and attach the additional source URLs to it. If the same board republishes the vacancy under a new ID, that is handled separately.
This part was more annoying than I expected. Company names are messy. Job titles are messy. Every board formats things differently. But simple normalization already solves a surprising amount of it.
Search
I originally planned to use PostgreSQL full-text search. The database even has a generated tsvector column and a GIN index. And then I did not use it. The current search just runs normalized LOWER(...) LIKE conditions over a small set of columns.
The main reason is Ukrainian content. PostgreSQL does not ship with a Ukrainian full-text dictionary, and for the current amount of data, simple search is fast enough and behaves exactly how I expect. So yes, I currently have a full-text search column and a GIN index sitting in the database while the actual search uses LIKE. And for now, that is completely fine.
Search also supports a few simple operators. For example:
ΡΠΈΠΏ:Π²Π°ΠΊΠ°Π½ΡΡΡLaravel"$3000+""Senior PHP"
Salary conditions apply only to jobs, quoted phrases are kept together, and section filters can narrow the results. I will probably revisit search later when the dataset gets bigger. Right now I would rather keep it boring and predictable.
Tests
The project has roughly 340 automated tests at the moment. There are unit tests, feature tests, Livewire tests and architecture tests. A lot of the tests cover things that are easy to break quietly. Salary parsing, job classification, source imports, deduplication, authentication, employer and developer cabinets, content APIs and HTTP pages.
PHPStan and Larastan run at level 8. Before deployment, I run Pest and PHPStan locally. If either fails, deployment stops. There is no GitHub Actions setup at the moment.
Deployment & Production Setup
Deployment is intentionally simple. The code is pushed to a bare Git repository on the server. A post-receive hook checks it out, and Docker Compose rebuilds the application. Migrations run when the container starts. It is not fancy, but it works. I can always make it more complicated later if there is a real reason.
The application runs with FrankenPHP and PostgreSQL 17. There is a separate scheduler container for Laravel's scheduler. Static files are served with compression and long cache headers. Cloudflare sits in front of the server.
Backups are also simple. A daily cron runs pg_dump, compresses the result and keeps 14 days of backups on the server. Again, nothing exciting. But I like having boring infrastructure that I understand.
AI Content Processing
There is one part where AI actually makes sense. The production server creates content tasks, but it does not call an LLM API directly. A local worker leases tasks through an API, runs Claude CLI, then sends the result back to the server.
The lease is protected with database locking and expires after 30 minutes. Each task can be attempted a limited number of times. Returned HTML is sanitized before it is saved. I like this setup because the production server does not need model API keys and the AI-related work can stay outside the main application runtime. It is still experimental, but it works pretty well.
Other Features
The project started as a jobs platform, but now it also has salary statistics, market snapshots, SEO landing pages, first-party analytics, employer applications, documentation and interview questions.
Employer applications use optimistic locking. If two updates try to modify the same application version, one of them fails instead of silently overwriting the other. Application history is stored separately as append-only events.
There is also first-party analytics without a persistent tracking cookie. The visitor ID is derived from date, IP and user agent, so it rotates every day.
I did not plan most of this when I started. It just grew naturally from the product.
Current State
The first commit was on September 2. At the time I collected these notes, the repository had around 400 commits. That is probably more about how I work than anything else. I prefer small commits and short feedback loops.
There is still a lot I want to improve. Search will probably change again. The employer side is still evolving. The content tooling is still experimental. There are parts of the architecture I already know I will simplify. But the main thing works. And I am pretty happy with the direction so far.
If you work with Laravel, job aggregation, search or similar data-heavy applications, I would be interested to hear how you would approach some of these parts.
Live project: https://phpukraine.com
Comments
No comments yet. Start the discussion.