A production-grade blog management system for government job notifications — built end-to-end in Laravel 11 for the JobYaari developer internship assignment.
Public site for readers + a full admin CMS with CKEditor 5, AJAX filters, FULLTEXT search, sanitized HTML, image processing, and a deployment-ready footprint.
- Public site:
https://<your-deployment-url>/ - Admin login:
https://<your-deployment-url>/admin/login
The deployment URL is filled in after pushing to Render. See Deployment below.
| Field | Value |
|---|---|
admin@jobyaari.com |
|
| Password | Admin@12345 |
Created by database/seeders/AdminUserSeeder.php on a fresh db:seed.
| Page | Path |
|---|---|
| Public landing | docs/screenshots/landing.png |
| AJAX filter in action | docs/screenshots/filter.png |
| Admin dashboard | docs/screenshots/dashboard.png |
| Blog editor | docs/screenshots/editor.png |
Capture these from your running deployment and drop into
docs/screenshots/.
- Hero + sticky filter bar: category pills, date range (Today/Week/Month/Year/All), sort (Newest/Oldest/Most Viewed), debounced search.
- AJAX-driven filters & pagination: no page reloads. URL stays in sync via
history.pushStateso any filter combination is shareable. - Responsive grid: 3 columns on desktop, 2 on tablet, 1 on mobile (tested at 375 px).
- Blog detail page: hero image, category badge, view counter (session-deduped), reading-time estimate, sanitized HTML content (tables, headings, lists, inline images), and Open Graph meta for shareable links.
- Recently viewed: last 5 blogs persisted in session as a fixed-size queue, shown on the listing page.
- Share buttons: WhatsApp, Twitter/X, LinkedIn, Copy Link — pure HTML/JS, zero third-party scripts.
- Empty / loading / error states on every async surface: friendly empty UI with "Clear filters", spinner overlay during AJAX, retry button on network failure.
- Login with email + password, "Remember me", and rate limiting (5 attempts / minute / IP via
Illuminate\Support\Facades\RateLimiter). - Dashboard: 4 stat cards (total blogs, total categories, most-viewed, latest blog), quick-action buttons, and a Chart.js doughnut showing blogs per category.
- Blog index: searchable + sortable table, server-side pagination, thumbnail column, AJAX delete via SweetAlert2 confirmation with row fade-out.
- Blog create / edit: CKEditor 5 Classic (headings H1–H4, bold, italic, underline, lists, blockquote, insertTable, imageUpload, link, undo/redo, removeFormat, sourceEditing), live char counter on short description, live image preview on file pick.
- Inline image upload to
/admin/blogs/upload-image— backed by Intervention Image v3, resized to ≤ 1200 px width, re-encoded to WebP with random filename. - Validation via dedicated
StoreBlogRequest/UpdateBlogRequestform requests. - Responsive admin: sidebar becomes a slide-out drawer below 768 px.
| Layer | Choice |
|---|---|
| Framework | Laravel 11.x |
| Language | PHP 8.2+ (developed against 8.3) |
| Database | MySQL 8 / MariaDB 10.11 (FULLTEXT + composite indexes) |
| Frontend | Blade, Bootstrap 5.3 (CDN), jQuery 3.7 (CDN) |
| Rich text | CKEditor 5 Classic 41.4 (CDN) with custom upload adapter |
| Icons / chart | Font Awesome 6 (CDN), Chart.js 4 (CDN) |
| Confirm dialog | SweetAlert2 (CDN) |
| Images | intervention/image v3 (GD driver, WebP output) |
| HTML sanitization | mews/purifier 3.4 (HTMLPurifier wrapper) |
| Auth | Laravel's built-in Auth + custom AdminAuth middleware |
| Typography | One pinned font: Inter (400, 600) via Google Fonts |
A few choices that aren't obvious from reading the code:
The /api/blogs/filter endpoint renders public/partials/blog-card.blade.php server-side and returns the HTML as part of the JSON payload. The frontend just swaps $('#blogGrid').html(res.html). This keeps cards consistent between server-rendered first paint and AJAX re-renders, eliminates a parallel JS template, and ensures any Blade helper (Carbon dates, route(), Str::limit, etc.) works identically in both code paths.
The most common public query is "give me blogs in category X, ordered by published_at DESC, paginated." A composite index on (category_id, published_at) lets MySQL/MariaDB satisfy both the WHERE and the ORDER BY from a single index scan — no filesort. The separate FULLTEXT index on (title, short_description) lets search use MATCH ... AGAINST in boolean mode (with a * wildcard suffix for prefix matches). For terms under 3 characters — below the FULLTEXT min-token-length — the service falls back to LIKE so behaviour stays correct.
Storing seen blog IDs in the user's session (viewed_blogs) gives a per-browser, per-session deduplication for the view counter without leaking any PII server-side and without needing a separate blog_views table. Increment uses DB::table('blogs')->where('id', $id)->increment('views') — a single UPDATE ... SET views = views + 1 — so concurrent views don't race. Tradeoff: a user who clears cookies will be re-counted; that's acceptable for this use case. For stricter dedup you'd add a blog_views table keyed on (blog_id, ip, day) or a Redis bloom filter.
CKEditor output is rendered with {!! !!}, which trusts the string. To make that safe, the controller passes content through clean() (mews/purifier → HTMLPurifier). The allowlist in config/purifier.php covers exactly the tags CKEditor produces (headings, lists, tables, images, links, blockquotes, formatting) while stripping <script>, inline JS, and on* attributes. This is more robust than a hand-rolled regex sanitizer and is the convention in the Laravel ecosystem.
CKEditor's image upload is wired through a tiny custom UploadAdapter in resources/views/admin/blogs/_form_scripts.blade.php that POSTs to /admin/blogs/upload-image. The endpoint validates MIME type + size, then delegates to ImageUploadService, which resizes to 1200 px max width and re-encodes to WebP with a randomized filename to prevent path traversal. Adding a heavy file-router package would be over-engineering for one endpoint.
A simple array_slice(array_unique(...), 0, 5) after each detail-page visit gives an O(1) (well, O(5)) LRU-style queue without any storage. Survives the session, costs nothing, and the listing page just whereIn's the IDs back into a single query.
Blog::generateUniqueSlug($title, $ignoreId) appends -2, -3, … until unique. Readable slugs are better for SEO and for human reviewers; a random suffix would hide collisions and look amateur. The $ignoreId parameter ensures update() doesn't bump the slug of the row being updated.
Default config/database.php for Laravel 11 sets collation: utf8mb4_0900_ai_ci (MySQL 8). MariaDB doesn't ship that collation. We switched the default to utf8mb4_unicode_ci in config/database.php (env-overridable as DB_COLLATION), which works on both servers and preserves all FULLTEXT / index features the schema relies on.
Requires PHP 8.2+, Composer, and MySQL 8 or MariaDB 10.11.
# 1. Clone
git clone <your-repo-url> jobyaari-blog
cd jobyaari-blog
# 2. Install dependencies
composer install
# 3. Environment
cp .env.example .env
php artisan key:generate
# 4. Edit .env — at minimum:
# DB_HOST, DB_PORT, DB_DATABASE (create empty DB), DB_USERNAME, DB_PASSWORD
# 5. Create DB:
mysql -u root -p -e "CREATE DATABASE jobyaari_blog CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
# 6. Migrate + seed (creates admin user, categories, 20 blogs with downloaded images)
php artisan migrate:fresh --seed
# 7. Serve
php artisan serve --host=127.0.0.1 --port=8000Visit:
- Public site: http://127.0.0.1:8000/
- Admin login: http://127.0.0.1:8000/admin/login
The seeder downloads 20 placeholder blog images from picsum.photos into public/uploads/blogs/. If your machine has no outbound network access, image fields stay NULL and the model's image_url accessor falls back to a placeholder URL — the site still works, you just won't see distinct cover images.
users
├── id, name, email (unique), password (bcrypt)
├── is_admin (bool, default false)
├── remember_token, email_verified_at
└── timestamps
categories
├── id, name (unique), slug (unique, indexed)
├── color (hex, for badge)
└── timestamps
blogs
├── id
├── title (indexed)
├── slug (unique, indexed)
├── short_description (string, 300 char)
├── content (longtext) — sanitized CKEditor HTML
├── image (string, nullable) — filename under public/uploads/blogs/
├── category_id (FK -> categories.id, cascade on delete)
├── views (unsigned bigint, default 0)
├── published_at (timestamp, indexed)
├── timestamps
├── INDEX (category_id, published_at) -- composite
└── FULLTEXT (title, short_description) -- search
Returns the filtered / searched / paginated grid as pre-rendered HTML plus metadata. Used by the public listing page; safe to call from anywhere that wants the same partials.
Query parameters
| Param | Type | Default | Notes |
|---|---|---|---|
category |
string | all |
Category slug (admit-card, latest-jobs, results, answer-key, syllabus) or all. |
date_range |
string | all |
today, week, month, year, all. |
sort |
string | newest |
newest, oldest, popular. |
search |
string | empty | Free-text query. Uses FULLTEXT (MATCH ... AGAINST in boolean mode with trailing *) when ≥ 3 chars; falls back to LIKE otherwise. |
page |
int | 1 |
Page number. 9 per page. |
Response
{
"html": "<article class=\"jy-card\">…</article>…",
"pagination": "<nav class=\"jy-pagination\">…</nav>",
"total": 42,
"page": 1,
"has_more": true
}html is a string of rendered <article class="jy-card"> blocks. pagination is the rendered pagination nav. Drop both straight into the DOM.
The repo is set up for Render with two deployment paths.
Render's free tier no longer includes MySQL. Use Aiven (free MySQL trial) or Clever Cloud (free MySQL up to 256 MB), then:
- Create the MySQL database on your provider, copy connection details.
- Push this repo to GitHub.
- On Render, New > Web Service, select the repo. The blueprint (
render.yaml) picks the Dockerfile automatically. - In Environment, set
APP_URL,DB_HOST,DB_PORT,DB_DATABASE,DB_USERNAME,DB_PASSWORDfrom your MySQL provider.APP_KEYauto-generates from the blueprint. - Deploy. The Dockerfile runs
migrate --force(and triesdb:seed --force) on first boot.
The included Procfile (web: vendor/bin/heroku-php-apache2 public/) works on Heroku and any platform that respects Procfile. Provision a MySQL add-on and set the same DB_* env vars.
APP_ENV=production,APP_DEBUG=falseAPP_KEYset (generated once)APP_URLmatches the deployment hostname (used for canonical + OG URLs)- Storage writable:
chmod -R 775 storage bootstrap/cache public/uploads - After deploy:
php artisan config:cache route:cache view:cache
Honest, prioritized:
- Redis cache for the filter endpoint (hot keys: category × date × sort × page). Current MySQL queries are already index-served, but cache wins on shared filter combinations.
- Cookie- or DB-backed view dedup instead of session, so a logout doesn't reset a returning reader's view history.
- In-memory trie autocomplete on blog titles (cache for 1 h on boot), exposed at
/api/blogs/suggest?q=.... - WYSIWYG image cropping at upload time (crop 16:9 in browser before sending to backend) so cover images always frame cleanly.
- Soft deletes + admin trash bin so accidental deletes are recoverable.
- Sitemap.xml + RSS for SEO indexing of category and blog detail URLs.
- PHPUnit feature tests for the filter service, admin CRUD, and view-counter dedup. Skipped here in favour of a working end-to-end demo.
-
php artisan migrate:fresh --seedsucceeds on a clean DB - Admin can log in with seeded credentials
- Admin can create / edit / delete blogs
- CKEditor toolbar includes headings, bullets, blockquote, table, inline image upload
- AJAX delete works with SweetAlert2 confirm + row fade-out
- Public listing pulls live from DB (no static blog data anywhere)
- Category, date, sort, search filters all combine correctly via AJAX
- URL stays in sync with filter state
- Empty state shows when filters return zero results
- Detail page increments view counter once per session
- Related blogs render and are from the same category
- Share buttons produce correct URLs (WhatsApp, X, LinkedIn, Copy Link)
- Responsive at 375 px — no horizontal scroll
- Admin sidebar collapses into a drawer on mobile
- CSRF tokens on every form + AJAX header
- Admin login rate-limited at 5 attempts / minute / IP
-
.envgitignored,.env.examplecommitted with no secrets - README complete