# CLAUDE.md

Guidance for Claude when working in this repository. Read it fully before writing any code.

## 1. About the project

This is a **Laravel-based CMS template / starter kit** (similar to WordPress, but built with Laravel). It is **not a shared product**. The workflow:

1. The template is copied into a new repo for each client (e.g. an oil company, an educational institution).
2. Unused modules are removed and client-specific modules are added.
3. The frontend is rebuilt per client inside the same repo.

Consequences:
- Each client has its own repo, database, and server. **There is no multi-tenancy.**
- Code must be easy to **understand, cut down, and modify**. Prefer simplicity over abstraction.
- Removing a module must be clean: delete its folder and disable it in `config/modules.php`.

## 2. Tech stack

| Area | Choice |
|---|---|
| Framework | Laravel 13, PHP 8.3 to 8.5 (minimum 8.3) |
| Database | MySQL / MariaDB (SQLite-compatible for testing) |
| Admin panel | **Blade + Livewire 3 + Alpine.js + Tailwind CSS** (custom-built) |
| Public frontend | Blade + Tailwind (minimal starter, rebuilt per client) |
| Rich text editor | Tiptap (via Alpine component) |
| Permissions | `spatie/laravel-permission` |
| Media | `spatie/laravel-medialibrary` + Intervention Image (WebP) |
| Activity log | `spatie/laravel-activitylog` |
| Backup | `spatie/laravel-backup` |
| Sitemap | `spatie/laravel-sitemap` |
| Slugs | `spatie/laravel-sluggable` |
| Auth | Laravel Fortify (login, password reset, 2FA) |
| Testing | Pest |
| Code style | Laravel Pint |

### Hard rules

- **Do NOT use Filament, Nova, Backpack, Voyager, or any other third-party admin panel.** The admin is built in-house with Blade components + Livewire.
- Do not add new packages without a clear reason. Ask first if unsure.
- Do not add Inertia, Vue, or React. The stack is server-rendered.

### Laravel 13 specifics

- The documentation reference is **laravel.com/docs/13.x**. Do not follow patterns from versions 11/12 if they changed in 13. When in doubt, check the 13.x docs first.
- Follow the current Laravel skeleton: middleware, exceptions, and routing are configured in `bootstrap/app.php`; there is **no `app/Http/Kernel.php`**.
- Laravel 13 supports **PHP Attributes** as an alternative to class properties for configuring components. They may be used when more concise, but stay **consistent**: pick one style per component type and do not mix them.
- Use PHP 8.3+ features where they help (typed class constants, `readonly` classes, `#[\Override]`).
- **Before installing any package**, confirm it is compatible with Laravel 13 (check the package's `composer.json` or run `composer require` and read the resolution result). If a package is incompatible, report it and propose alternatives. Do not downgrade the framework.
- The Laravel AI SDK and built-in vector search are **not used** in this template (out of scope for a CMS). Do not add them unless asked.
- Make sure local, staging, and production all run PHP 8.3+, since `composer update` fails on PHP 8.2.

## 3. Directory structure

```
app/
  Core/                     # foundation shared by all modules
    Support/                # helpers, traits, base classes
    Livewire/               # generic admin components (DataTable, MediaPicker, etc.)
    Http/Middleware/        # RedirectManager, CheckModuleEnabled, EnsureInstalled, etc.
    Providers/
    Setup/                  # setup wizard: Actions (shared with app:install), Livewire steps, lock file logic
  Modules/
    Article/                # CORE
    Page/                   # CORE
    Category/               # CORE (categories & tags)
    Media/                  # CORE
    Seo/                    # CORE
    Setting/                # CORE
    User/                   # CORE (users, roles, permissions)
    Comment/                # optional (visitor comments on articles, moderation; requires Article)
    Activity/               # optional (read-only activity log page)
    Backup/                 # optional (backups page: list, run, download, delete)
    Transfer/               # optional (import and export of articles, pages, categories and tags as JSON)
    Analytics/              # optional (page views of articles and pages: counts per day, totals in lists, dashboard chart; listens to ContentViewed)
    Revision/               # optional (a version per save of articles and pages, compare and restore; autosave of unsaved form changes per person)
    Update/                 # optional (daily check of release.json: new DigiCMS release and module versions in System > Updates and the bell; update:manifest)
    # Add-ons (Menu, Redirect, Contact, Banner, Gallery, Testimonial, Faq, Newsletter) are NOT in the template:
    # they live in the sibling folder ../digi-template-addons and are copied in per client (see its README).
config/
  modules.php               # list of enabled modules
resources/
  views/
    admin/                  # admin layout & pages (never mix with frontend)
    components/admin/       # admin Blade components (table, form, modal, etc.)
    frontend/               # CLIENT AREA: public layout & pages
  js/
    admin.js
    frontend.js
routes/
  web.php                   # loads frontend routes
  admin.php                 # admin routes (prefix /admin, auth middleware)
database/
  seeders/
    CoreSeeder.php          # roles, permissions, default settings (required)
    DemoSeeder.php          # sample content (safe to delete)
```

### Structure of a single module

Every module is **self-contained**:

```
app/Modules/Article/
  Models/
  Http/
    Controllers/            # frontend & admin controllers
    Requests/
  Livewire/                 # admin tables/forms for this module
  Policies/
  Actions/
  Database/
    Migrations/
    Seeders/
  Resources/
    views/
      admin/
      frontend/             # default views (clients may override)
    lang/
  routes/
    admin.php
    web.php
  ArticleServiceProvider.php
  module.php                # metadata: name, description, requires, permissions, admin menu (group, icon), notices, charts, recent
```

Each module has a `ServiceProvider` that registers routes, views (namespace `article::`), migrations, and translations. The provider is loaded only if the module is enabled in `config/modules.php`.

## 4. Code conventions

### General
- PSR-12 via Pint. Run `vendor/bin/pint` before finishing.
- `declare(strict_types=1);` in new PHP files.
- Type hints and return types on all methods.
- Use **Form Requests** for validation, not inline validation in controllers.
- Keep controllers thin. Complex business logic goes into **Action classes** (`app/Modules/X/Actions/`) or Services.
- Use **Policies** for authorization. Every admin action must check permissions.
- Use PHP enums for statuses (`ArticleStatus::Draft`, etc.), not raw strings.
- Avoid N+1 queries: eager load, and enable `Model::preventLazyLoading()` in non-production environments.

### Naming
- Tables: plural snake_case (`articles`, `article_tag`).
- Models: singular PascalCase (`Article`).
- Permissions: `module.action` (e.g. `article.create`, `article.publish`, `media.delete`).
- Route names: `admin.article.index`, `article.show` (frontend).
- Module view namespace: `article::admin.index`.

### Database
- All migrations must be **reversible** (correct `down()`).
- Use `softDeletes()` for main content (articles, pages).
- Add indexes for frequently filtered columns (`status`, `published_at`, `slug`).
- Never edit a migration that has already run in a template used by a client. Create a new migration instead.

## 5. Building the admin (without Filament)

Because there is no CRUD generator, we maintain **our own admin components** so new modules are quick to build and the UI stays consistent.

Components that must exist in `app/Core/Livewire` and `resources/views/components/admin`:
- `DataTable` (Livewire): search, filters, sorting, pagination, bulk actions, per-page selector.
- Form components: input, textarea, select, toggle, date picker, slug input, tag input.
- `RichEditor` (Alpine + Tiptap) with server-side HTML sanitization.
- `MediaPicker` (modal for choosing/uploading from the media library).
- `SeoFields` (meta title, description, canonical, robots, OG) with a Google result preview.
- Confirmation modal, toast notifications, empty state, admin breadcrumb.
- Responsive admin layout (collapsible sidebar, mobile-friendly).

Rules:
- The admin sidebar is built automatically from each enabled module's `module.php`.
- All admin pages use the layout `resources/views/admin/layouts/app.blade.php`.
- Use Livewire for interactions (tables, forms with realtime validation). Use Alpine for small client-side interactions.
- Do not write custom CSS when Tailwind utilities are enough.

### Admin look and feel (shadcn-admin, rebuilt in Blade)

The admin follows the design of **shadcn/ui and shadcn-admin** (zinc neutrals, a green primary taken from the DigiCMS logo, rounded cards with thin borders, a grouped collapsible sidebar, light and dark mode). It is rebuilt with Blade + Tailwind, **not** with React: the hard rule above (no React/Vue/Inertia) still stands, so never copy shadcn-admin's React code, only its design.

- **Tokens, not colours.** `resources/css/admin.css` defines the design tokens as CSS variables for light and dark (`--background`, `--card`, `--primary`, `--muted`, `--border`, `--destructive`, `--sidebar` ...). Use the semantic utilities: `bg-background`, `bg-card`, `bg-muted`, `text-foreground`, `text-muted-foreground`, `border-border`, `text-destructive`, `bg-primary`. **Never** hard-code `bg-white`, `text-black`, or `gray-*` / `red-*` shades in admin views: they break dark mode. `AdminThemeTest` fails the build if you do. Change the brand colour in one place: the `--primary` pair in `admin.css`.
- **Colours.** Tokens: `primary` (green: the main action), `info` (blue), `warning` (amber), `destructive` (red), each with a `-foreground`. `<x-admin.button variant="default|secondary|info|warning|destructive|outline|ghost|link">`; row actions take `tone="primary|info|warning|destructive"` by meaning (edit = info, view/approve/restore = primary, unapprove/spam/disable = warning, delete = destructive).
- **Tables.** A row's actions are `<x-admin.row-actions>` with one `<x-admin.row-action icon label>` per action (icon only, `tone="destructive"` for delete; inside a component tag use `{{ Js::from() }}` not `@js`). Text in a cell is cut to 30 characters with "..." by the DataTable (column option `limit`, `0` = never; `nowrap` for a column that must stay on one line; `responsive` => 'xl' or '2xl' hides a column below that screen width); in a custom cell use `<x-admin.truncate :text="..." />`.
- **Build with the components** in `resources/views/components/admin/`: `button` (variants default, secondary, info, warning, destructive, outline, ghost, link; sizes default, sm, icon), `input`, `textarea`, `select`, `toggle` (a switch), `card`, `badge` (variants success, warning, info, secondary, destructive, outline), `page-header`, `breadcrumb`, `empty-state`, `modal`, `confirm-dialog`, `toasts`, `icon`, `row-actions` and `row-action`, `truncate`, `chart-bars` and `chart-donut`, `avatar`, `language-switcher`, `timezone-select`, `command-palette`, `rich-editor`, `seo-fields`, `slug-input`, `tag-input`. Do not restyle them per page. Status badges: give the enum a `badgeVariant()` and print `<x-admin.badge :variant="...">`. Do not mix an inline `@php(...)` with a `@php ... @endphp` block in one Blade file, and do not use `@js()` or `@class()` inside a component tag (`BladeDirectivesTest` catches both).
- **Icons** are Lucide-style inline SVG: `<x-admin.icon name="newspaper" class="size-4" />`. Add a new icon by adding its markup to the map in `icon.blade.php`.
- **Sidebar and dashboard come from `module.php`.** Each `menu` entry takes `group` (one of `ModuleRegistry::GROUPS`: Content, Media, Engagement, Users, System, Settings) and `icon`. Every group is a fold in the sidebar (the one with the open page, and the first, start open; the choice is remembered). A menu entry may carry `expand` (`source` class + `method`) to stand for several entries: Settings does this, one entry per settings group (General, Contact, SEO, Comments ...), each at `/admin/settings/{group}`. A settings group has a `label`, an `icon`, a `description` and optional `sections` (a field names its section) in `settings.php`. A group is shown in the sidebar as an entry with the icon of `ModuleRegistry::GROUP_ICONS`; one with a single page is that page.
- **Dashboard.** No total cards. Charts come from `charts` (`label`, `type` 'bars' with a `date` column or 'donut' with a `group` column, `model`, optional `scope`, `permission`, `route`, `order`), drawn as SVG by `<x-admin.chart-bars>` and `<x-admin.chart-donut>` (no JS chart library); a module may add a "Latest ..." list with `recent` (`label`, `model`, `title`, `route` taking the id, `permission`, optional enum `status`). "Needs your attention" shows the `notices` and, for people who may change settings, `SecurityChecks::problems()`.
- **Modules page.** Settings > Modules lists every module (core ones are locked on) and switches the optional ones on and off with `ModuleSwitcher`, which edits `config/modules.php` as text, runs the migrations and seeds the permissions of a module that is switched on, and refuses when another enabled module needs it. It needs the permission `module.manage` and is closed in production, where `config/modules.php` ships with the code.
- **Page views.** The Article and Page controllers fire `ContentViewed` (not for previews); the Analytics module listens, skips bots, prefetch, staff and a repeat visit the same day (settings), and adds to one row per item per day in `page_views`. A chart declares `sum` to add a column per day; a `recent` list may name a `source` class instead of a model.
- **Footer.** The admin layout ends with `<x-admin.footer>`: the template version (`TEMPLATE_VERSION`) and "Built by Digitalku" linking to https://digitalku.com. It is hidden by `ADMIN_FOOTER=false` (`config('app.admin_footer')`); keep it in the layout, a client project edits the component.
- **Notifications bell.** A module lists things that need attention under `notices` in `module.php` (`label`, `route`, `permission`, `icon`, and `model` + `scope` or `source` + `method` returning a count). Zero counts are hidden.
- **Command palette (Ctrl/Cmd+K).** It lists the sidebar pages and the `commands` a module declares in `module.php` (`label`, `route`, `permission`, `icon`, optional `order`: the "Create ..." shortcuts, which the dashboard also shows as quick actions), filtered by the person's permissions. Give a module with a create page a command.
- **Accounts.** Your own profile (`/admin/profile`: name, email, picture, password, two-factor with Fortify's actions, each sensitive step asks for the current password). A user can be invited by email (they choose their own password), disabled (cannot sign in, sessions ended; `EnsureAccountIsActive` signs them out mid-session) and never the last active super admin. Show a person with `<x-admin.avatar :user="$user">`.
- **Two-factor reminder.** `<x-admin.two-factor-prompt>` in the admin layout covers every admin page (not the profile) for a person without confirmed two-factor authentication, with "Set it up now" (profile `#two-factor`) and "Skip for now" (`admin.two-factor-prompt.skip`, stores `users.two_factor_prompt_skipped_at`; back after `TwoFactorPrompt::SKIP_HOURS` = 24). Logic in `App\Modules\User\Support\TwoFactorPrompt`; `ADMIN_TWO_FACTOR_PROMPT=false` (`config('app.admin_two_factor_prompt')`) switches it off.
- **Media.** Files can be put in folders, replaced in place (same id, same kind of file) and show where they are used: a module that uses library files registers a finder with `MediaUsageRegistry` in its provider (see Article).
- **Dark mode** is toggled from the header and remembered in `localStorage`; an inline script in `<head>` applies it before the first paint. Do not rely on `prefers-color-scheme` alone.
- `stubs/module/` already follows all of this; keep new stubs on tokens too.

## 6. SEO guidelines

This template must make SEO easy for the frontend developer:

- A `HasSeo` trait on models (Article, Page, etc.) providing a relation to the polymorphic `seo_meta` table.
- A Blade component `<x-seo :model="$article" />` that renders: title, meta description, canonical, robots, Open Graph, Twitter Card, and JSON-LD.
- Automatic fallbacks: if meta fields are empty, use the content's title, excerpt, and thumbnail.
- `sitemap.xml` generated automatically (scheduler/command) from published models.
- `robots.txt` editable from the admin (stored in settings).
- **Redirect manager** (Redirect add-on; core fires `SlugChanged`): a middleware matching URLs against a `redirects` table (301/302). When a slug changes, a redirect is created automatically.
- RSS feed for articles.
- Canonical URLs are always absolute and consistent.

## 7. Frontend helpers

The frontend developer (me, per client) must be able to build pages without touching core logic. Provide:

```php
setting('site_name');              // fetch a setting (cached)
setting('contact.email', 'default');
menu('header');                    // fetch a menu as a tree (Menu add-on)
module_enabled('gallery');         // check whether a module is enabled
```

```blade
<x-seo :model="$article" />
<x-frontend.breadcrumb :items="$items" />
@foreach (menu('footer') as $item) ... @endforeach   {{-- Menu add-on --}}
```

A view composer exposes `$siteSettings` to the frontend layout.

Add-ons and shared views (the same applies to optional modules): Blade resolves `<x-component>` tags **when it compiles a view**, so an `@if (module_enabled('banner'))` around `<x-banner />` does not protect a site where that module is off (it fails with "Unable to locate a class or view for component"). Views that every site uses (the starter layout and home page, core modules) may only use components of core modules. Reach optional ones with `<x-dynamic-component component="banner" ... />` inside the `module_enabled()` check. A test (`OptionalModuleViewsTest`) enforces this.

## 7b. Multi-language

The site can be in several languages (English and Indonesian ship; add more in `config/locales.php`). With a single entry in `available` every language control disappears. There are two separate things, and both are tested:

**Interface language** (admin screens, forms, emails, validation messages)
- Write every visible string as `__('English text')`; the English text is the key. Never build sentences by concatenation: use placeholders, `__('Delete :name?', ['name' => $x])`, and `trans_choice` for plurals.
- Translations live in `lang/id.json` (plus `lang/id/validation.php`, `auth.php`, `passwords.php`, `pagination.php`). A module may ship its own `Resources/lang/id.json`, which is loaded automatically when the module is enabled; add-ons use this.
- Definitions that are data (menu `label` and `group` in `module.php`, dashboard `charts`, `recent` and `notices`, `settings.php` labels and hints, DataTable column, filter and bulk-action labels, breadcrumb labels, enum `label()`) keep plain English and are translated **where they are printed**. Toast messages are translated by `WithToast` and the toasts component.
- Do not use a string that equals a framework translation file name as a key (`'Pagination'` returns an array): the test suite catches it.
- `TranslationTest` fails when a `__()` string, a menu or settings label has no Indonesian text, when a translation drops a `:placeholder`, or when the framework messages are incomplete. Add the Indonesian text with the English one.
- The admin language is the cookie `admin_locale` (switcher in the admin header); the public site uses the cookie `locale`. `SetLocale` (web group) applies them; Livewire requests are matched to admin or site by their Referer.
- **Default language is `config('locales.default')` (`APP_LOCALE`), never `config('app.locale')`**: Laravel rewrites `app.locale` for each request. Use `App\Core\Support\Locales` (`default()`, `codes()`, `multiple()`, `isValid()`).

**Content language** (articles, pages, categories, translatable settings)
- One row per language ("Polylang style"): each version is a complete record with its own title, slug, status and SEO, and the versions of one piece of content share `translation_group`. Columns `locale` and `translation_group` come from `TranslationColumns::add($table)` in a migration; the model uses the `Translatable` trait (`inLocale()`, `otherTranslations()`, `translationUrls()`, `missingLocales()`, `createTranslation($locale)`).
- To make another module translatable: the migration, the trait, a language field in its form (`WithTranslations` + `<x-admin.translations>`), `localeColumn()`/`localeFilter()` in its table, `->inLocale()` in its public queries, and `translationUrls()` for the switcher. Tags, redirects and menu items are not translated rows.
- Public pages: filter with `->inLocale()`; a detail page resolves its slug in any language and calls `Locales::adopt($model->locale)`, so a shared link opens in the right language. Pass `translationUrls()` to `<x-frontend.language-switcher>` (section `language_switcher`) and `<x-seo>` adds `hreflang` links by itself.
- Settings: `'translatable' => true` in `settings.php`; other languages are stored as `key@locale` and fall back to the default language's value.
- Changing a version's language after it is saved is not allowed (it would break the one-version-per-language rule); a new version is created from the Language card of the form.

## 7c. Dates, time zone and site settings

- The database keeps **UTC** (`config('app.timezone')` stays `'UTC'`; do not change it). The site's time zone, date format and time format are settings (Settings > General) and are applied where dates are shown or typed.
- Show a date with `site_date($d)`, `site_time($d)` or `site_datetime($d)`; never `->format('Y-m-d')` on a model date in a view or table. For a `datetime-local` input use `SiteFormat::toInput($date)` to fill it and `SiteFormat::fromInput($string)` to read it back (it returns UTC). `site_timezone()` is the zone name.
- The favicon is a Media library image id (`site_favicon`); layouts print `site_favicon_url() ?? asset('favicon.png')`.
- The **Address** setting (`site_address`) overrides `config('app.url')` for public pages and console commands so absolute links (sitemap, canonical, social cards) use it. It is **never** applied to `admin`, sign-in, `livewire` or `setup` (`SiteFormat::isPrivateRequest`), so a wrong address cannot lock anyone out.
- A module can add settings with its own `app/Modules/<Name>/settings.php` (same format as the Setting module's). Field types: text, email, url, number, textarea, select, timezone, media, password (stored encrypted, never sent back to the browser, blank keeps the saved value; mark it `'secret' => true` so it stays out of `$siteSettings`). Each group is a page of its own under Settings in the sidebar (`/admin/settings/{group}`) with a `label`, an `icon`, a `description`, optional `sections` (cards with a title) and fields that name their `section`. Shipped: General, Contact, Email (SMTP in the database, applied by `MailSettings`, `.env` stays in effect until "How to send" is chosen; test email; docs/email.md), SEO, Media (largest upload), Articles (per page, feed length), Comments, Activity log (retention), Page views, Revisions (versions kept, autosave interval).

## 8b. Security headers, rate limits and the frontend cache

- `SecurityHeaders` (outermost middleware) sends the headers and the Content-Security-Policy from `config/security.php`. The public site's policy
  allows **no inline or eval scripts**: do not add `<script>` blocks or `onclick="..."` to public views (JSON-LD `type="application/ld+json"` is fine);
  put JS in `resources/js/frontend.js`. Admin and setup views may use Alpine and inline scripts. An external origin goes in `config/security.php`.
- `ThrottleSensitiveForms` limits password-reset forms and every page per visitor. A new public form needs its own limiter (see the comment form),
  honeypot and signed time token. A "type your password" prompt in the admin goes through `ProfileGuard::confirm()`.
- Public queries go through `FrontendCache::remember($key, fn)`; a model they read uses `FlushesFrontendCache`. Never cache anything that depends on the
  visitor. `QueryCountTest` must stay green: eager load, and a page needs the same queries for 2 and for 12 rows.
- Every admin route needs `can:` middleware or a policy check in the Livewire component (`SecurityTest` opens them all as a user with only `admin.access`).

## 8. Security (mandatory)

- Sanitize HTML from the rich editor on the server (use `mews/purifier` or `symfony/html-sanitizer`). **Never** render user content with `{!! !!}` without sanitization.
- Validate uploads: whitelist mime types and extensions, enforce size limits, never trust client file names, store with random names.
- Rate limit login, contact form, and public endpoints.
- Spam protection on public forms: honeypot + Cloudflare Turnstile (optional via setting).
- CSRF enabled on all forms. Never disable security middleware.
- Mass assignment: use explicit `$fillable`, not `$guarded = []`.
- Never store secrets in code. Everything goes through `.env`.
- Every sensitive action (delete, role change, settings change) is recorded in the activity log.

## 9. Performance

- Cache settings and menus (`Cache::rememberForever`), invalidate on change.
- Cache heavy frontend queries with cache tags when the driver supports them.
- Convert images to WebP and generate size variants (thumb, medium, large) via queue.
- Emails and media processing run on the queue.
- Use `loading="lazy"` and `width`/`height` attributes on images in the bundled frontend components.

## 10. Custom Artisan commands

Build and maintain the following commands:

| Command | Purpose |
|---|---|
| `php artisan app:install` | New-project setup wizard (CLI): client name, `.env`, migrate, seed core data, create first admin. The browser version is the setup wizard below |
| `php artisan make:module Name` | Generate a new module skeleton from stubs |
| `php artisan module:list` | List modules and their enabled/disabled status |
| `php artisan module:remove Name` | Cleanly remove a module (folder + config entry) |
| `php artisan app:clean-demo` | Remove demo data and seeders |
| `php artisan app:dummy-data` | Development only: neutral dummy content spread over the last days (articles, pages, comments, users, images, log), so the dashboard has something to show. `--clean` removes exactly what it made (manifest `storage/app/dummy-manifest.json`) |
| `php artisan seo:sitemap` | Regenerate the sitemap |

Module stubs live in `stubs/module/` (see its README for the placeholder tokens). If you introduce a new convention, update the stubs.

How the commands behave (the logic lives in `app/Core/Modules/`, the commands are thin wrappers):

- `make:module Name` generates a working CRUD module (migration, model, policy, Livewire table and form, admin views, routes, permissions in `module.php`, and a Pest test in `tests/Feature/Name/`) and registers it in `config/modules.php`. It refuses duplicate names, clashing tables or admin URLs, and leaves nothing behind if generation fails. It edits `config/modules.php` as text, so comments survive.
- `module:list` shows each module's status (enabled, disabled, not registered, missing folder) and flags unmet `requires` from `module.php`.
- `module:remove Name` deletes the folder, `tests/Feature/Name/` and the config entry. It refuses when another module lists it in `requires` (override with `--force`), supports `--dry-run`, and with `--drop-data` first rolls back the module's migrations and deletes its permissions (nothing else is removed if that fails). Always declare cross-module dependencies in `requires`.
- Backups: the configuration is core infrastructure, `config/backup.php` (database + `storage/app/public` only, never `.env`), scheduled by `CoreServiceProvider` when `BACKUP_SCHEDULE=true`; `BackupTest` guards it. The optional Backup module adds the admin page. "Back up now" is dispatched with `dispatchAfterResponse()` so it runs without a queue worker, and a backup left "queued" for over two minutes no longer blocks the next one. A failed backup (also the nightly one) feeds the notifications bell.
- `app:clean-demo` deletes exactly the records `DemoSeeder` created (it records their IDs in `storage/app/demo-manifest.json`, never guessing from titles) and then deletes `DemoSeeder.php`. `DemoSeeder` must stay neutral and must record every record it creates. `app:install --demo` installs it.

### Setup wizard (web installer)

Besides `php artisan app:install`, the template ships a **browser-based setup wizard** so a fresh copy can be configured without a terminal (like the WordPress installer). It lives in `app/Core/Setup/` and is part of the core, not a module.

- **Route:** `/setup`. It is only reachable while the app is **not installed**. Once finished, `/setup` returns 404 (or redirects to `/admin`) and can never be re-run from the browser.
- **Installed state:** a `storage/app/installed` lock file written at the end of the wizard (also written by `app:install`). Do not rely on the database alone to decide this, because the database may not exist yet.
- **Redirect:** while not installed, a middleware sends every public and admin request to `/setup`. Assets and `/up` stay reachable.
- **Steps:**
  1. **Requirements check:** PHP version (8.3+), required extensions (`gd`, `fileinfo`, `mbstring`, `pdo` driver, `exif`), writable `storage/` and `bootstrap/cache/`, `public/storage` link.
  2. **Database:** driver (MySQL/MariaDB/SQLite), host, port, name, user, password. Test the connection before continuing and write the values to `.env`.
  3. **Site:** site name, tagline, site URL (`APP_URL`), time zone (a searchable list), default language (`APP_LOCALE`, only when several languages exist; the wizard itself is translated and has a language switcher at `/setup/language/{code}`), time zones listed with their UTC offset so `+7` can be searched, which optional modules to enable (written to `config/modules.php`; core modules in `config/setup.php` cannot be switched off), and whether to add the demo content. A module that is switched off gets no tables, because only the migrations of enabled modules are run. Locale is not asked: only English exists.
  4. **Admin account:** name, email, password (min 8 chars, confirmed).
  5. **Finish:** run migrations, run `CoreSeeder`, create the first `super-admin`, run `storage:link`, save settings, write the lock file, then redirect to `/login`.
- **Single source of truth:** the wizard and `app:install` must call the **same Action classes** (`app/Core/Setup/Actions/`), never duplicate the logic. Keep the Artisan command a thin wrapper.
- **Security (mandatory):**
  - Never leave `/setup` open after install; check the lock file on every request to it.
  - Validate and escape every input, and never echo the database password back into the page.
  - Writing `.env` must be atomic and must preserve unrelated keys; never write user input unquoted (escape quotes and newlines).
  - Rate limit the wizard's POST endpoints, keep CSRF enabled, and generate `APP_KEY` if it is missing.
  - If the environment is `production` and the app is already installed, the wizard must refuse to run even if the lock file is deleted while a database with users exists.
- **Blade + controller, not Livewire:** the wizard is plain server-rendered forms (`SetupController`, `resources/views/setup/`) with its own self-contained inline CSS, so it works before a database, a session table or built assets exist. Do not add Livewire, Alpine or Vite to it.
- **Before a database exists:** `SetupServiceProvider` must stay the **first** provider in `bootstrap/providers.php`. For `/setup` requests of an uninstalled site it switches sessions, cache and queue to file/sync and generates `APP_KEY`, in `register()`. Doing this later is too late: other providers (Fortify's rate limiters, Livewire) resolve the cache in `boot()` and would keep a database cache that does not exist.
- **Self-healing lock:** `InstallState::isInstalled()` also treats a site that already has users as installed and writes the lock, so an older project or a deleted lock file can never expose `/setup`. The test suite switches enforcement off with `SETUP_ENFORCE=false` in `phpunit.xml`; wizard tests turn it on and use temporary storage, `.env` and `modules.php` (bind `EnvWriter` and `ModuleConfig` to temp files).
- **Tests:** Pest feature tests for each step, the requirements check, the lock behaviour (wizard unreachable after install, everything redirects before install), `.env` writing (quotes, preserved keys), and that `app:install` and the wizard produce the same result.

## 11. Testing

- Write Pest tests for every new feature (feature tests for admin and frontend flows, unit tests for Actions/Services).
- At minimum test: CRUD, validation, authorization (a user without permission must be denied), and slug/redirect behavior.
- Run `php artisan test` and `vendor/bin/pint --test` before declaring work done.
- **Tests never touch the real project.** Anything a feature writes outside the test database (`config/modules.php`, `storage/app/*-manifest.json`, `.env`, uploads) is pointed at a temporary file or `Storage::fake()` in the test (`ModuleConfig` is bound to a copy, `DummyData::usePath()`), and cleaned up. A test that deletes a real file is a bug: one did, and lost the dummy-data manifest of a development site.
- A page that needs new Tailwind classes needs `npm run build` before it looks right in the browser; the tests do not see that.

## 12. How Claude should work in this repo

1. **Read first**: look at a similar existing module before creating a new one, and follow its pattern.
2. For new features, give a short plan (files to create/change) before writing code when the change is large.
3. Work on **one module/feature per step**. Do not mix many things at once.
4. Do not modify `app/Core` for the needs of a single module. If necessary, explain why.
5. Do not put client-specific examples (oil, education, etc.) in the template. The template must stay **neutral**.
6. Every new module must have: migrations, model, policy, permissions in `module.php`, admin pages, and tests.
7. When finished, run Pint and the tests, then summarize what changed and what remains.
8. If a design decision is ambiguous, ask. Do not guess.

## 13. Versioning and drift

This template will be copied into many client projects, so each project may end up on a different version.

- Tag template releases (`v1.0.0`, etc.).
- Record all changes in `CHANGELOG.md` (flag security-relevant changes).
- Each client project records its originating template version in `TEMPLATE_VERSION`.
- Every template module has a `version` in `module.php`. A release raises the versions of the modules it changes, renames `[Unreleased]` in the changelog, sets `TEMPLATE_VERSION` and regenerates `release.json` with `php artisan update:manifest` (never by hand, never for unreleased work). Sites read that file from `main` (Update module). Client projects pull a release in with `php artisan update:apply` (three-way per file: replaces what the project never changed, writes `<file>.new` beside what it changed; never the public site). A file that clients rebuild completely belongs in `ignore` of `app/Modules/Update/config.php`. See `docs/upgrading.md`.

## 14. Roadmap

**Phase 1: Foundation**
- Install Laravel 13, Tailwind, Livewire, Alpine, Pest, Pint
- Auth (Fortify) + roles/permissions + activity log
- Admin layout + base components (DataTable, forms, modal, toast)
- Modules: Setting, User, Media, Category/Tag, Article, basic SEO

**Phase 2: Core completeness**
- Page module (Menu builder and Redirect manager became add-ons)
- Sitemap, robots.txt, RSS, JSON-LD
- Contact form + email notifications (now the Contact add-on)
- Frontend helpers & components, minimal frontend starter

**Phase 3: Tooling and optional modules**
- `app:install`, `make:module`, `module:remove`, `app:clean-demo`
- Setup wizard (web installer at `/setup`, see section 10), sharing its Actions with `app:install`
- Banner, Gallery, Testimonial/FAQ, Newsletter modules (now add-ons in `../digi-template-addons`)
- Multi-language (interface and content, see 7b), backup, documentation

## 15. Out of scope for this template

- No multi-tenancy.
- No Filament or any third-party admin panel.
- No public API or API versioning (add per client if needed).
- No industry-specific features (oil products, admissions, etc.). Those are built as modules in the client repo.
