Portfolio site and CMS
I built my own site as a content platform instead of installing a template, so the way it is made backs up what the pages say. Posts and case studies are documents of typed blocks, with ordering, media, caching and tests that hold up.
A case study by Arif Hossen
Source: https://arifhossen.dev/work/portfolio-site-and-cms
- Role: Architect and engineer
- Team: Me, with an AI assistant
- Duration: Ongoing, 5 weeks in
- Stack: Laravel 13, Inertia 3, React 19, MySQL
- Period: Sep 2026, ongoing
- Engagement: Own project
- 619 Commits on the day this was written
- 8 Block types, each with its own typed payload
- 1,108 Browser-side tests passing
- 134 Pest test files on the server
The site makes the claim
Context
A developer's own site makes a claim about how they build. The cheapest way to make it is to write it on an about page, and the site underneath is usually a template, which quietly makes the opposite claim. I wanted the site itself to be the evidence.
So I built it as a small content platform. It has to be finishable by one person in spare time, which means every feature has to earn its place. Where I took a shortcut, the reason is written next to the code instead of in a separate document.
Documents made of typed blocks
The document model
A post or a case study is a document: ordered sections, each holding ordered blocks. There are eight block types, among them prose, code, table, stat grid, note and card grid. Each has its own payload class that validates itself and reports its search text and the media it references. The editor is form-based, not rich text, so it is predictable and testable, and the editor and the public page share one block registry.
<!-- diagram:document_model -->
Diagram: The document model
Posts and case studies share one document shape and one editor. Every write is an action that declares which cached trees it invalidates.
Sections attach to their parent polymorphically, through a small interface, so adding case studies after posts needed no second editor. Four publishing statuses sit on top, with a scheduled publisher and two guards. A case study cannot be published without a body, and the last section of a published one cannot be deleted. Without them, deleting one section would silently drop a live page out of the index.
Reordering writes one row
Ordering
Dragging a block up three places with integer positions rewrites every row after it. The write cost grows with the document, and two overlapping edits can lose one. I used lexicographic rank keys over a 62-character alphabet instead. A move allocates a key strictly between the two neighbours, so one row is written at any document length.
The rank column uses a binary collation, because the database's default case-insensitive sort would disagree with the allocator at the edges. The technique has a known cost: inserting repeatedly into the same gap makes keys grow without bound. The rank class says so in a comment, declines to bound the length itself and hands the problem to a rebalancer that rewrites a group to evenly spaced keys. The rebalancer is also a console command. Two inserts racing for the same gap raise a named conflict instead of writing through it.
A file is public if a published page uses it
Media
Two questions about uploaded files are usually answered badly. Is this image still used? That gets answered by searching content for its address, which is meaningless once content is structured data. And who can see this file? That gets answered by whether someone knows its address, which quietly makes every draft's screenshots public.
A usage table records which documents reference which files. It is refreshed on every write and rebuilt by a sweep. Deleting a file that is still referenced raises a specific in-use error. The same computation is the access rule: the public media route asks whether the file is reachable from published content before it serves it, so an unpublished draft's images are not readable by anyone who guesses an identifier. An audit command reports drift and exits non-zero.
Caches that declare their invalidation
Caching
Public index pages are served from cache in three windows. They are fresh for five minutes, stale for fifteen while a refresh runs behind the request, and kept for a day as a backstop so a slow query still has something to serve. Every key is namespaced under a schema version, so changing the shape of what is cached is one increment away from invalidating all of it. That constant has reached 8, and each bump is recorded with its reason.
Invalidation lives with the change that causes it. Content actions implement an interface that declares which caches they flush, so no call site has to remember to clear anything. The cache class takes identifiers instead of models, and says why in a comment, which keeps it free of the layer it serves.
Counting reads without a write per view
Readership
I wanted readership numbers without a database write on the hottest path and without a third-party script that hands visitors' browsing to someone else. A middleware on the post route counts each read in Redis. It filters a declared list of bot and tool user agents and ignores the same reader for thirty minutes.
A scheduled command drains the counters into a durable table every fifteen minutes, one row per post per day under a unique constraint, so a repeated drain cannot double count. The cost is fifteen-minute granularity, which is the right trade for this purpose.
Specs first, tests always
Process
Each slice of the site began as a written spec, became a plan of small tasks and was built task by task. Nineteen specs sit in the repository. Settled decisions and traps are recorded as rules that the next change has to read, and a script fails the check if a stock colour, a raw pixel value or the old line height creeps into the design system.
The suite is 134 Pest files on the server and 103 Vitest files in the browser, with 1,108 browser-side tests passing when this was written. The unit tests concentrate on the parts that look right and are subtly wrong: ordering, media reachability and identifier validation.
I set the direction, approved each spec and plan, and decided what merged. An AI coding assistant wrote much of the code under those specs, and each task was reviewed by a second agent before the next one began. I say it here because the specs, the review loop and the tests are the part that is mine, and they are what I would defend in an interview.
What is not done
What I would do differently
Post dates on the public pages print the UTC calendar date, not the date in the owner's time zone. A post published in the first six hours after midnight in Bangladesh therefore shows the previous day. The fix is to share the settings time zone with the public pages, and I have not done it yet.
There is no site search. The inverted index was designed and deferred. And everything here has run on one site with one editor, not under production load, so I make no claim about scale.