A multilingual food & lifestyle journal rebuilt from scratch — Laravel 13 backend, Filament admin, editorial Blade/Alpine/Tailwind frontend, and a one-shot import of 145 articles from the legacy Sentinel-era database, then machine-translated into three locales via the Claude API.
- What: A Portuguese food/nutrition journal that has existed since 2015. The site is being rebuilt on a modern stack while preserving all content, images, authors, categories, and tags from the legacy MySQL dump.
- Why: Replace the Cartalyst Sentinel / Laravel 5-era codebase with a stack that is easier to maintain, adds a proper admin UI, and scales to three locales (pt-PT as source, plus pt-BR and en).
- How: Test-driven rebuild in 14 steps — scaffold, schema, admin, import, translate, route, theme, SEO, perf. 132 tests (unit, feature, architecture, browser smoke) backing the whole thing.
| Layer | Choice |
|---|---|
| Language / runtime | PHP 8.4 |
| Framework | Laravel 13 |
| Admin panel | Filament 4 + filament/spatie-laravel-media-library-plugin |
| Auth / roles | Built-in Laravel auth + spatie/laravel-permission |
| Media | spatie/laravel-medialibrary (cover + content collections per article, avatar per user) |
| Frontend | Blade + Alpine.js 3 (light usage) |
| Styling | Tailwind CSS 4 + DaisyUI 5 (custom canela theme in oklch) |
| Typography | Fraunces (headings) + Inter (body), bundled via @fontsource/* |
| Build | Vite 8 via laravel-vite-plugin |
| AI translation | Anthropic PHP SDK (anthropic-ai/sdk), Claude Opus 4.7 default, prompt caching on the system prompt |
| Tests | Pest 4 (Unit + Feature + Browser via Playwright + Architecture) |
| Code style | Laravel Pint |
| Static analysis / refactor | Rector |
| Package discovery | Laravel Boost MCP |
- PHP 8.4 with extensions:
intl,bcmath,mbstring,gd,exif,fileinfo,zip,curl,pdo_mysql- On Fedora:
sudo dnf install -y php-intl php-bcmath php-mbstring php-gd php-exif php-zip
- On Fedora:
- Composer 2
- Node.js 20+ and npm
- MySQL 8+ (tested against MySQL 9.5)
- Playwright (auto-installed by the browser test plugin;
npx playwright install chromiumthe first time) - Anthropic API key (only if you want to run
canela:translate)
# 1. Clone & install
composer install
npm install
# 2. Env
cp .env.example .env
php artisan key:generate
# Edit .env: DB_HOST / DB_PORT / DB_DATABASE / DB_USERNAME / DB_PASSWORD
# Plus: ANTHROPIC_API_KEY=... if you want translations
# DB_LEGACY_DATABASE=canela_legacy (default)
# 3. Build assets
npm run build
# 4. Create the new app database + the legacy staging database
mysql -u root -p -e "CREATE DATABASE canelaegengibre;"
mysql -u root -p -e "CREATE DATABASE canela_legacy CHARACTER SET utf8 COLLATE utf8_unicode_ci;"
# 5. Migrate the new schema + seed roles
php artisan migrate --seed
# 6. Link storage/app/public → public/storage so uploaded media is web-served
php artisan storage:link
# 7. Load the legacy dump into canela_legacy
mysql -u root -p canela_legacy < database/dumps/canela-2025-12-22.sql
# 8. Import legacy content (users, categories, tags, articles, media)
php artisan canela:import --images=/path/to/legacy/images
# 9. (Optional) Translate pt_PT → pt_BR + en
php artisan canela:translate --entity=articles --locales=pt_BR,en
php artisan canela:translate --entity=categories --locales=pt_BR,en
php artisan canela:translate --entity=tags --locales=pt_BR,en
# 10. Promote the owner account to super-admin (so you can log into /admin)
php artisan tinker --execute 'App\Models\User::where("email","cvarandas@gmail.com")->first()->assignRole("super-admin");'Media storage. All uploaded files — article covers, inline content images, user avatars — land under
storage/app/public/images/{media-id}/{filename}and are served at/storage/images/…. Theimages/prefix is controlled byMEDIA_PREFIX=imagesin.env(set by default). If you change it, existing files need to be moved or re-imported.
composer run devruns the full dev stack (Laravel + queue worker + pail + Vite) viaconcurrently.- Or individually:
php artisan serve,npm run dev,php artisan queue:listen,php artisan pail.
| Path | Purpose |
|---|---|
/ |
Homepage (pt-PT — default locale, unprefixed) |
/artigos · /artigos/{slug} |
Articles listing + single article (pt-PT) |
/categorias/{slug} |
Category listing (pt-PT) |
/tag/{slug} |
Tag listing (pt-PT) |
/pesquisa?q=… |
Search results (pt-PT, throttled 60/min) |
/sobre |
About page (pt-PT) |
/pt-br/… |
Same routes in Brazilian Portuguese (/pt-br/artigos, /pt-br/categorias, /pt-br/tag, /pt-br/busca, /pt-br/sobre) — disabled in v1 |
/en/… |
English (/en/articles, /en/category, /en/tag, /en/search, /en/about) — disabled in v1 |
/admin |
Filament admin panel (login required, throttled 300/min) |
/sitemap.xml |
Per-locale URL sitemap (1-hour cache) |
/robots.txt |
Dynamic — sitemap pointer, /admin disallowed |
/up |
Laravel health endpoint |
| Command | What it does |
|---|---|
canela:import --images={path} [--fresh] [--limit-articles=N] |
Imports users, categories, tags (with dedupe), articles, translations (pt-PT), cover + inline content media from the legacy legacy DB connection. Idempotent with --fresh. |
canela:translate --entity={articles|categories|tags} [--locales=pt_BR,en] [--article=ID] [--limit=N] [--dry-run] |
Batch-translates pt-PT translations into missing target locales using Claude. Skips records that already have all target locales. |
canela:publish-scheduled |
Promotes Scheduled articles whose published_at has arrived to Published. Wired to run every minute in routes/console.php. |
- ArticleResource — per-locale tabs (title, slug, excerpt, content, meta_title, meta_description), status enum (Draft / Pending / Scheduled / Published), featured toggle, author + category selects, tag multi-select, cover upload (media-library, MIME-restricted ≤ 4 MB), inline image attachments via
RichEditor(hardened upload). Authors see only their own articles; super-admins see everything. - CategoryResource — 2-level tree (parent select), per-locale name/slug/description tabs, super-admin only.
- TagResource — per-locale name/slug tabs, authors can create/edit (so they can tag new articles), no delete.
- UserResource — profile + role multi-select (
spatie/permission), password optional on edit (hash preserved), avatar via media-library. The roles select is gated on the current user holdingsuper-admin(visible + dehydrated guard) so authors cannot self-elevate. - RedirectResource — from_path → to_path with HTTP code (301 / 302 / 307 / 308), super-admin only, unique from_path.
to_pathmust start with/(server-side regex + open-redirect guard inbootstrap/app.php). - Hardening — entire panel under
throttle:300,1; the public site adds aSecurityHeadersmiddleware (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, X-Permitted-Cross-Domain-Policies, plus HSTS when HTTPS in production).
Custom DaisyUI theme canela in resources/css/app.css, mapped to the palette in docs/ideas/template.md:
| Token | Value |
|---|---|
| cinnamon (primary) | #A85B32 |
| ginger (accent) | #D98C3B |
| oat (base bg) | #FDF7EF |
| moss (secondary) | #54624E |
| ink (text) | #1F2937 |
Typography: Fraunces for headings, Inter for body. Both bundled, no external CDN.
Homepage composition (final shape, after polish rounds):
- Sticky translucent header (CG monogram · nav with hover dropdown for sub-categories · inline search field on
md+, icon-link on mobile · locale switcher) - Hero — 60/40 split on
lg+, ~400px landscape height (cinnamon→ink gradient featured article on the left with a "Featured" pill + "Read article" CTA, 4 stacked compact cards on the right) - Category pills with Alpine panel swap — preloaded, 4 articles per parent category aggregated across descendants, grid-stacked panels for flicker-free transitions
- Latest carousel — Alpine module (
resources/js/carousel.js), stride-based scroll-snap, 78% card width on mobile, dot pagination - Footer (sticky to bottom of short pages via
mt-auto)
Image pipeline: every cover renders through coverImageAttrs() which emits srcset from spatie media conversions (thumb 600×450 webp, hero 1600×900 webp) plus a sizes hint and loading="lazy" (eager + fetchpriority="high" only on the hero).
pt-PT lives at the root (so legacy URLs like canelaegengibre.pt/artigos/{slug} keep resolving with zero redirect). pt-BR and en are prefixed.
| Locale | Home | Articles | Categories | About |
|---|---|---|---|---|
| pt-PT (default) | / |
/artigos/{slug} |
/categorias/{slug} |
/sobre |
| pt-BR | /pt-br/ |
/pt-br/artigos/{slug} |
/pt-br/categorias/{slug} |
/pt-br/sobre |
| en | /en/ |
/en/articles/{slug} |
/en/category/{slug} |
/en/about |
- Slugs are unique per locale (not globally) — the translation tables enforce
UNIQUE(locale, slug). - Locales are gated by
enabled => trueinconfig/canela.php. Disabled locales drop their route group entirely (so/en/and/pt-br/404 in v1) and are excluded from<link rel="alternate" hreflang>. The default-locale segment also emits anx-defaultalternate. - Missing translations return 404 rather than rendering a half-translated fallback.
- Legacy 404s are caught by a render hook (
bootstrap/app.php) that checks theredirectstable and emits a 301/302/307/308 if there's a match. Destinations are required to start with/to prevent open redirects. - UI strings translated via
lang/pt_PT.jsonandlang/pt_BR.json; keys fall back to English (source) when missing.pt_PTfalls back to theptdirectory so vendor (Filament/Laravel) translations resolve.
Legacy dump: database/dumps/canela-2025-12-22.sql (145 articles, 9 categories, 185 tags, 3 users). Loaded once into a canela_legacy MySQL database. The canela:import command reads that via a dedicated legacy connection in config/database.php and writes to the primary connection.
Schema mapping (legacy → new):
| Legacy | New | Notes |
|---|---|---|
users.first_name + last_name |
users.name |
concatenated on import |
users.info |
users.bio |
renamed |
users.permissions/active/confirmed/last_login |
— | dropped (Sentinel cruft) |
roles + role_users |
spatie/permission | |
categories.lft/rgt/depth/icon/menu |
— | dropped, only parent_id + sort kept |
article_types |
— | filtered to type_id=1 (Post) |
articles.state (tinyint) |
articles.status (enum) |
Lavalite convention: state ≥ 1 → Published, otherwise Draft (confirmed against real data: 140 rows use state=1, a couple of edge cases use 0/-1) |
articles.media_featured |
Spatie media cover collection |
file copied from --images dir to storage/app/public/images/… |
Inline <img src="/uploads/media/images/…"> |
Spatie media content collection |
auto-copied to the same prefixed folder |
tags.namespace/slug/name/count |
tags + tag_translations |
namespace + count dropped; near-duplicates merged via TagMergeMap |
tagged.taggable_type/id/tag_id |
article_tag pivot |
polymorphism dropped (only articles existed) |
| — | redirects |
new table for legacy-path preservation |
Import run against the real dump:
- 3 users (legacy bcrypt hashes preserved via raw
DB::table()insert) - 9 categories → 9 pt-PT translations
- 177 canonical tags (from 184 legacy; 7 near-duplicate slugs merged:
receitas/receitasrapidas,pao/paodealfarroba,panqueca/panquecas,sem-acucar,refeicoes,pos-treino, …) - 145 articles → 145 pt-PT translations
- 747 article-tag pivot rows
- 145 cover images + 66 inline content images moved into spatie media
- End-to-end ~4s
Single entry point: php artisan canela:translate --entity=… --locales=….
- The
TranslatorContractinterface is bound toClaudeTranslatorinAppServiceProvider(container-swappable in tests, seetests/Support/FakeTranslator.phpandtests/Feature/Console/TranslateArticlesTest.php). ClaudeTranslatorbuilds one request per record withsource_locale,target_locales, and afieldsobject; the system prompt (cached viacache_control: ephemeral) specifies the preservation rules — keep every HTML tag/attribute intact, translate text nodes only, produce natural pt-BR vocabulary (café da manhã,xícara, …), editorial English, and per-locale slugs.- Model default:
claude-opus-4-7. Swap viaANTHROPIC_MODEL=claude-sonnet-4-6in.envfor a cheaper pass. - The command is idempotent (skips records already having all target locales), supports
--dry-run,--limit,--article, and--entity=articles|categories|tags. - Each record's locale upserts run inside a single
DB::transactionso partial writes never leave a record half-translated.
Rough cost for 145 articles × 2 locales on Opus 4.7 (input ~2K + output 4K per call): **$18** input+output. Sonnet 4.6 is ~1/5 that.
# Everything (unit + feature + browser + arch)
./vendor/bin/pest --compact
# One suite
./vendor/bin/pest --testsuite=Unit
./vendor/bin/pest --testsuite=Feature
./vendor/bin/pest --testsuite=Browser
# Filter
./vendor/bin/pest --filter=ArticleResourceTest --compact
# Lint / format
./vendor/bin/pint --dirty --format agentCoverage snapshot:
| Suite | Examples |
|---|---|
| Unit | ArticleStatus enum, ContentSanitizer, TagMergeMap, ClaudeTranslator JSON parsing |
| Feature / Frontend | locale routing (incl. disabled-locale 404s), homepage content, search (min length, throttle, drafts/future filtered, pagination), tag + category pages, SEO (sitemap/robots/meta/JSON-LD/hreflang/x-default), security headers, article XSS regression, cache headers |
| Feature / Filament | admin access gates + every resource (Articles, Categories, Tags, Users, Redirects) with per-locale form fills + policy authorization + roles-gate guard + open-redirect guard |
| Feature / Models | relationships, fallback translation chain, scopes, unique slug-per-locale |
| Feature / Console | canela:publish-scheduled, canela:translate end-to-end with a fake translator (success, throw, dry-run) |
| Feature / Import | fails without --images; imports a single article end-to-end (skipped if legacy DB unreachable) |
| Architecture | no dd/dump, Controllers extend Controller + suffixed, Models extend Eloquent (Concerns excluded), Enums are enums, Services namespaced |
| Browser | route smoke loop × assertNoSmoke() (no JS errors, no console logs), hero click nav |
| Total | 132 tests / 291 assertions (7 skipped while pt-BR/en are disabled) — ~10s wall clock |
The browser suite uses pestphp/pest-plugin-browser + Playwright Chromium. RefreshDatabase + sqlite-in-memory keeps each test isolated.
app/
Console/Commands/ # canela:import, canela:translate, canela:publish-scheduled
Enums/ # ArticleStatus
Filament/Admin/
Concerns/SyncsTranslations.php
Resources/ # Article, Category, Tag, User, Redirect
Helpers/ # see app/helpers.php (autoloaded via composer.json)
Http/
Controllers/ # Home, Article, Category, Tag, Search, About, Sitemap
Middleware/ # SetLocale, CachePublicResponse, SecurityHeaders
Models/ # Article, ArticleTranslation, Category, CategoryTranslation,
# Tag, TagTranslation, Redirect, User
Concerns/ # HasLocaleTranslations trait
Policies/ # ArticlePolicy (+ Category/Tag/User/Redirect)
Providers/ # AppServiceProvider, Filament/AdminPanelProvider
Services/
Import/ # ContentSanitizer, TagMergeMap
Translation/ # TranslatorContract, ClaudeTranslator
app/helpers.php # enabled_locales(), locale_route(), locale_route_for()
config/
canela.php # locale config (default + per-locale URL segments + enabled flag)
media-library.php # spatie/media-library config (prefix="images", disk="public")
database/
dumps/ # legacy SQL (gitignored)
migrations/ # users profile fields, categories, category_translations,
# tags, tag_translations, articles, article_translations,
# article_tag, redirects, permission tables, media
factories/
seeders/RoleSeeder.php
lang/ # pt_PT.json, pt_BR.json (en falls through to keys)
resources/
css/app.css # Tailwind v4 + DaisyUI canela theme + fonts
js/
app.js # Alpine entry, registers `carousel` data
carousel.js # Alpine module — stride-based scroll-snap carousel
views/
layouts/app.blade.php # shell with meta/OG/hreflang/JSON-LD slot
components/ # header (with sub-category dropdown + inline search), footer,
# article-card (responsive srcset + lazy)
home.blade.php # hero + categories + latest carousel
search.blade.php
articles/index, show
categories/show
tags/show
about.blade.php
pagination/canela.blade.php # custom paginator wired in AppServiceProvider
sitemap.blade.php
routes/
web.php # per-locale route groups + legacy redirect fallback
console.php # scheduled canela:publish-scheduled
tests/
Unit/ Feature/ Browser/ Support/
Before pointing DNS at this build:
.env— setAPP_ENV=production,APP_DEBUG=false,APP_KEY=…(runphp artisan key:generate),SESSION_ENCRYPT=true,ANTHROPIC_API_KEY=…only if you'll re-run translations.php artisan migrate --force && php artisan db:seed --forcefor the role seeder.php artisan storage:link.npm ci && npm run build.php artisan optimize(config/route/event cache). Skipview:cacheuntil after Filament discover runs once.- Ensure HTTPS is terminated upstream —
SecurityHeadersmiddleware adds HSTS automatically when the request is HTTPS inproduction. - Run the legacy import once (
canela:import --images=…); the command is idempotent under--fresh. - The Filament admin is throttled to 300 requests/minute per IP;
/pesquisais throttled to 60/minute.
- Inline content image URLs — legacy content is sanitized (HTML entities decoded, absolute
canelaegengibre.pthost stripped), and the actual image files are moved into the spatiecontentmedia collection (served under/storage/images/…). However, the HTML body of each article still references the legacy-style paths (/uploads/media/images/…), which now 404. A second pass to rewrite each inline<img src>to the new spatie media URL per article is TODO. - Comments — intentionally not implemented (not in the legacy data, not requested).
- pt-BR / en locales — wired end-to-end but disabled in
config/canela.phpfor v1; flipenabled => trueper locale to ship them. The translation command and routes are already in place. - FULLTEXT search — current search uses LIKE. Drop in a MySQL FULLTEXT index on
article_translations(title, excerpt)once content volume justifies it.
Personal project — no license specified.