diff --git a/.gitattributes b/.gitattributes
index bb6265e..87d4535 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -9,3 +9,14 @@
/.scrutinizer.yml export-ignore
/tests export-ignore
/.editorconfig export-ignore
+/.github export-ignore
+/pint.json export-ignore
+/AGENTS.md export-ignore
+/CLAUDE.md export-ignore
+/package.json export-ignore
+/pnpm-lock.yaml export-ignore
+/tsconfig.json export-ignore
+/vitest.config.ts export-ignore
+/eslint.config.js export-ignore
+/.prettierrc.json export-ignore
+/.prettierignore export-ignore
diff --git a/.github/coverage.svg b/.github/coverage.svg
deleted file mode 100644
index 8cb18f9..0000000
--- a/.github/coverage.svg
+++ /dev/null
@@ -1,20 +0,0 @@
-
\ No newline at end of file
diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml
deleted file mode 100644
index 9fe8e22..0000000
--- a/.github/workflows/run-tests.yml
+++ /dev/null
@@ -1,73 +0,0 @@
-name: tests
-
-on:
- pull_request:
- branches:
- - main
- push:
- branches:
- - main
- - dev
-
-jobs:
- tests:
- runs-on: ${{ matrix.os }}
-
- strategy:
- fail-fast: true
- matrix:
- os: [ ubuntu-latest ]
- php: [ 8.4, 8.3, 8.2 ]
- laravel: [ 13.*, 12.*, 11.*, 10.* ]
- stability: [ prefer-lowest, prefer-stable ]
- include:
- - laravel: 13.*
- testbench: 11.*
- carbon: ^3.8.4
- - laravel: 12.*
- testbench: 10.*
- carbon: 3.*
- - laravel: 11.*
- testbench: ^9.16
- carbon: ^2.63
- - laravel: 10.*
- testbench: 8.*
- carbon: ^2.63
- exclude:
- - laravel: 13.*
- php: 8.2
-
- name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }}
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
-
- - name: Install SQLite 3
- run: |
- sudo apt-get update
- sudo apt-get install sqlite3 -y
-
- - name: Setup PHP
- uses: shivammathur/setup-php@v2
- with:
- php-version: ${{ matrix.php }}
- extensions: curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, iconv
- coverage: ${{ startsWith(github.event.head_commit.message, 'coverage') && matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' && 'xdebug' || 'none' }}
-
- - name: Install dependencies
- run: |
- composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" "nesbot/carbon:${{ matrix.carbon }}" --no-interaction --no-update
- composer update --${{ matrix.stability }} --prefer-dist --no-interaction
-
- - name: Execute tests
- run: |
- vendor/bin/phpunit ${{ startsWith(github.event.head_commit.message, 'coverage') && matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' && '--coverage-clover=clover.xml' || '' }}
-
- - name: Make code coverage badge
- if: startsWith(github.event.head_commit.message, 'coverage') && matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable'
- uses: timkrase/phpunit-coverage-badge@v1.2.1
- with:
- coverage_badge_path: .github/coverage.svg
- push_badge: true
- repo_token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..be87f86
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,236 @@
+name: tests
+
+on:
+ push:
+ branches:
+ - main
+ - next-gen
+ pull_request:
+
+# Unreleased javaabu/* dependencies come from VCS repositories; the job's own
+# token keeps Composer within GitHub's API limits
+env:
+ COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ github.token }}"}}'
+
+jobs:
+ tests:
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['8.4', '8.5']
+ laravel: ['12.*', '13.*']
+ stability: [prefer-lowest, prefer-stable]
+ include:
+ - laravel: '12.*'
+ testbench: '10.*'
+ - laravel: '13.*'
+ testbench: '11.*'
+
+ name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} - ${{ matrix.stability }}
+
+ env:
+ COVERAGE: ${{ matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v5
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, iconv, intl
+ coverage: ${{ env.COVERAGE == 'true' && 'xdebug' || 'none' }}
+
+ - name: Get Composer cache directory
+ id: composer-cache
+ run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
+
+ - name: Cache Composer downloads
+ uses: actions/cache@v6
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: composer-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }}-${{ hashFiles('composer.json', 'composer.lock') }}
+ restore-keys: |
+ composer-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }}-
+ composer-${{ runner.os }}-${{ github.job }}-
+
+ - name: Install dependencies
+ run: |
+ composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --dev --no-interaction --no-update
+ composer update --${{ matrix.stability }} --prefer-dist --no-interaction --no-progress
+
+ - name: Execute tests
+ run: vendor/bin/phpunit ${{ env.COVERAGE == 'true' && '--coverage-clover=clover.xml' || '' }}
+
+ - name: Upload coverage to Codecov
+ if: env.COVERAGE == 'true'
+ uses: codecov/codecov-action@v5
+ with:
+ files: clover.xml
+ fail_ci_if_error: false
+ token: ${{ secrets.CODECOV_TOKEN }}
+
+ pint:
+ runs-on: ubuntu-latest
+
+ name: Pint
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v5
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.4'
+ coverage: none
+
+ - name: Get Composer cache directory
+ id: composer-cache
+ run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
+
+ - name: Cache Composer downloads
+ uses: actions/cache@v6
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: composer-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }}-${{ hashFiles('composer.json', 'composer.lock') }}
+ restore-keys: |
+ composer-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }}-
+ composer-${{ runner.os }}-${{ github.job }}-
+
+ - name: Install dependencies
+ run: composer update --prefer-dist --no-interaction --no-progress
+
+ - name: Check code style
+ run: vendor/bin/pint --test
+
+ - name: No javaabu/settings or javaabu/helpers in production dependencies
+ run: |
+ if composer show --tree --no-dev | grep -E 'javaabu/(settings|helpers)'; then
+ echo "javaabu/translatable must not require javaabu/settings or javaabu/helpers (see #11)"
+ exit 1
+ fi
+
+ without-helpers:
+ runs-on: ubuntu-latest
+
+ name: Tests without javaabu/helpers
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v5
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.4'
+ extensions: curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, iconv, intl
+ coverage: none
+
+ - name: Get Composer cache directory
+ id: composer-cache
+ run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
+
+ - name: Cache Composer downloads
+ uses: actions/cache@v6
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: composer-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }}-${{ hashFiles('composer.json', 'composer.lock') }}
+ restore-keys: |
+ composer-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }}-
+ composer-${{ runner.os }}-${{ github.job }}-
+
+ - name: Install dependencies without javaabu/helpers
+ run: |
+ composer remove --dev javaabu/helpers --no-update
+ composer update --prefer-dist --no-interaction --no-progress
+
+ - name: Check javaabu/helpers isn't installed
+ run: "! composer show javaabu/helpers"
+
+ - name: Execute tests
+ run: vendor/bin/phpunit --exclude-group helpers
+
+ immutable-dates:
+ runs-on: ubuntu-latest
+
+ name: Tests with CarbonImmutable
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v5
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.4'
+ extensions: curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, iconv, intl
+ coverage: none
+
+ - name: Get Composer cache directory
+ id: composer-cache
+ run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
+
+ - name: Cache Composer downloads
+ uses: actions/cache@v6
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: composer-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }}-${{ hashFiles('composer.json', 'composer.lock') }}
+ restore-keys: |
+ composer-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }}-
+ composer-${{ runner.os }}-${{ github.job }}-
+
+ - name: Install dependencies
+ run: composer update --prefer-dist --no-interaction --no-progress
+
+ # Laravel's starter kits call Date::use(CarbonImmutable::class) (#12)
+ - name: Execute tests with Date::use(CarbonImmutable::class)
+ run: vendor/bin/phpunit
+ env:
+ TRANSLATABLE_IMMUTABLE_DATES: 1
+
+ js:
+ runs-on: ubuntu-latest
+
+ name: React components
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v5
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v5
+ with:
+ node-version: 22
+ package-manager-cache: false
+
+ # The default store is inside a node_modules folder, which breaks the declaration
+ # build of @javaabu/forms (installed from git and built on install)
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile --store-dir "$RUNNER_TEMP/pnpm-store"
+
+ - name: Lint
+ run: pnpm lint
+
+ - name: Check formatting
+ run: pnpm format:check
+
+ - name: Type check
+ run: pnpm typecheck
+
+ - name: Execute tests
+ run: pnpm test:coverage
+
+ - name: Upload coverage to Codecov
+ uses: codecov/codecov-action@v5
+ with:
+ files: coverage/clover.xml
+ flags: js
+ fail_ci_if_error: false
+ token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 7a3e63f..11a2aba 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,9 @@ build
composer.lock
vendor
coverage
+clover.xml
+node_modules
.phpunit.result.cache
+.phpunit.cache
.php-cs-fixer.cache
.idea
diff --git a/.php_cs.dist.php b/.php_cs.dist.php
deleted file mode 100644
index 8648fdc..0000000
--- a/.php_cs.dist.php
+++ /dev/null
@@ -1,35 +0,0 @@
-in([
- __DIR__ . '/src',
- __DIR__ . '/tests',
- ])
- ->name('*.php')
- ->notName('*.blade.php')
- ->ignoreDotFiles(true)
- ->ignoreVCS(true);
-
-return (new PhpCsFixer\Config())
- ->setRules([
- '@PSR12' => true,
- 'array_syntax' => ['syntax' => 'short'],
- 'ordered_imports' => ['sort_algorithm' => 'alpha'],
- 'no_unused_imports' => true,
- 'not_operator_with_successor_space' => true,
- 'trailing_comma_in_multiline' => true,
- 'phpdoc_scalar' => true,
- 'unary_operator_spaces' => true,
- 'binary_operator_spaces' => true,
- 'blank_line_before_statement' => [
- 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'],
- ],
- 'phpdoc_single_line_var_spacing' => true,
- 'phpdoc_var_without_name' => true,
- 'method_argument_space' => [
- 'on_multiline' => 'ensure_fully_multiline',
- 'keep_multiple_spaces_after_comma' => true,
- ],
- 'single_trait_insert_per_statement' => true,
- ])
- ->setFinder($finder);
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..ef44fb7
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,3 @@
+vendor
+node_modules
+coverage
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..6c1772b
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,5 @@
+{
+ "semi": false,
+ "printWidth": 120,
+ "tabWidth": 2
+}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..5580a68
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,45 @@
+# Translatable: notes for AI coding agents
+
+`javaabu/translatable` makes Eloquent attributes multilingual (JSON on the row, or a row per locale), and ships Blade (javaabu/forms v2) and Inertia React (@javaabu/forms, through javaabu/manifest) components. Its static `Translatable` API is the contract other next-gen packages use for optional translation support.
+
+## Layout
+
+| Path | What |
+| --- | --- |
+| `src/Translatable.php` | The static contract (`enabled()`, `supports()`, `locales()`, `inputs()`, `rules()`, `values()`, `status()`, `shared()`, …) and v2's instance methods |
+| `src/Locale.php`, `src/LocaleInput.php` | Value objects the contract returns; their `toArray()` shapes match `resources/js/types.ts` |
+| `src/Traits/IsTranslatable.php` | Shared model behaviour: attribute get/set, suffixes, staging, `fill()` |
+| `src/JsonTranslatable`, `src/DbTranslatable` | The two storage traits and their schema macros |
+| `src/Languages.php`, `src/LanguageRegistrar.php`, `src/Models/Language.php` | The languages, their cache, and the current locale (scoped) |
+| `src/Views/Components` | Blade components; they extend javaabu/forms' base component |
+| `resources/views/common` | Blade views, composed only of `` components |
+| `resources/js` | React components, imported by apps as `@javaabu/translatable` |
+| `src/Testing/InteractsWithLanguages.php` | Test helper for apps and packages |
+| `lang/{en,dv}/strings.php`, `resources/js/locales` | Every user-facing string; kept identical by `tests/Unit/StringsTest.php` |
+| `docs`, `llms.txt`, `resources/boost` | Docs, the assistant index, Laravel Boost guideline and skill |
+
+## Rules
+
+- **The static methods of `Translatable` and the `Locale`/`LocaleInput` array shapes are a public contract** for other packages. Don't change a signature or key within a major; add with defaults. Update `docs/advanced-usage/for-package-authors.md` and the API reference.
+- **Both storage types, always.** A model behaviour change needs tests for JSON (`Article`, `Page`) and DB (`Post`) models.
+- **Input names are `{field}_{locale}`** for every locale. Setting them stages translations until `save()`; `addTranslation()` saves.
+- **Both stacks.** A view change goes in `resources/views/common` and `resources/js`, with the same names, data and strings, and tests in PHPUnit (both forms frameworks via the `frameworks` data provider) and Vitest.
+- **No framework classes in Blade views.** Compose javaabu/forms components; use `$layoutClasses()` for the rare wrapper.
+- **npm packages for the React components go in manifest's `pages` scope** (`npm(..., for: 'pages')`); only something a registered Blade asset imports belongs in the default `assets` scope. `tests/Feature/Views/ManifestCheckTest.php` checks a Blade app isn't asked for them.
+- **javaabu/forms, javaabu/manifest and Inertia are optional.** Never reference their classes outside the views/registration code paths, and only by string (`::class` on an unknown class is fine; instantiating isn't).
+- **Dates may be immutable.** Apps call `Date::use(CarbonImmutable::class)`, so type-hint `DateTimeInterface` or `CarbonInterface` (never `Carbon\Carbon`) and use the return value of `add…()`/`sub…()`. CI runs the suite with `TRANSLATABLE_IMMUTABLE_DATES=1`; `protected bool $immutable_dates = true` does it for one test class.
+- **Never require javaabu/helpers or javaabu/settings** (helpers pins settings v1; see #11). Only `Models/AdminLanguage.php` may use helpers; CI fails if either is in the production tree, and runs the tests without helpers (`--exclude-group helpers`).
+- **No hard-coded text.** Add a line to `lang/en`, `lang/dv` and `resources/js/locales/{en,dv}.ts`; list new Dhivehi lines for review in `docs/advanced-usage/translations-and-rtl.md`.
+- **RTL:** logical utilities only; never `left`/`right`.
+- PHP follows Javaabu's guidelines (`vendor/javaabu/boost-javaabu-guidelines`): Pint, `#[Test]` and `it_…` test names, snake_case variables. Tests use Thaana for user-facing behaviour.
+- Every change needs tests, docs (and `llms.txt` for new pages), the Boost skill if usage changes, and a `CHANGELOG.md` line.
+- Work happens on `next-gen` until v3 is merged; never commit to `main`.
+
+## Commands
+
+```bash
+composer install && npx pnpm@10.28.0 install # pnpm 10.28 (see package.json packageManager)
+composer test # PHPUnit
+composer lint # Pint (composer format to fix)
+pnpm lint && pnpm format:check && pnpm typecheck && pnpm test
+```
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8869e77..d3e6c6f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,32 @@
# Changelog
-All notable changes will be documented in this file
+All notable changes to `javaabu/translatable` are documented in this file.
-## 1.0.0 - 202X-XX-XX
+## 3.0.0 - Unreleased
-- initial release
+- Requires PHP 8.4 and Laravel 12 or 13
+- javaabu/helpers is no longer required (it pinned javaabu/settings v1). `Language` keeps `admin_url`, `admin_link_name` and `search()`; apps that need helpers' `AdminModel` set `translatable.language_model` to `Models\AdminLanguage`. CI checks that neither helpers nor settings is in the production dependency tree
+- Javaabu codestyle enforced with Pint; CI runs PHP 8.4/8.5 × Laravel 12/13 × lowest/stable, with coverage on Codecov
+- **Contract for other packages:** `Translatable::enabled()`, `supports()`, `locales()`, `inputs()`, `rules()`, `values()`, `status()` and `shared()`, with `Locale` and `LocaleInput` value objects. See `docs/advanced-usage/for-package-authors.md`
+- `Javaabu\Translatable\Testing\InteractsWithLanguages` for tests in apps and packages
+- `php artisan translatable:add-languages en dv` adds languages from built-in presets
+- Models can list their fields in `protected array $translatable` instead of overriding `getTranslatables()`
+- Setting a suffixed attribute (`$post->title_dv = …`, `fill(['title_dv' => …])`) stages the translation until the model is saved, for both storage types; `fill()` accepts per-locale inputs on DB translatable models too
+- `addTranslation()` accepts any value, not only strings
+- Rows without a `lang` are read as the default locale, not the current one
+- JSON translatable models cast `translations` to an array themselves
+- The languages list is cached once per request, an empty list isn't cached, and changing a language clears the cache
+- `Languages` and the registrar are scoped, so the current locale doesn't leak between Octane requests
+- `languages.local_name` column (the language's own name); `add_local_name_to_languages_table` migration for v2 apps
+- New config: `enabled`, `thaana_locales`, `views`, `inertia`
+- Works with immutable dates (`Date::use(CarbonImmutable::class)`, the Laravel starter kit default); CI runs the tests with them too
+- New DB translation rows get their own `created_at` / `updated_at` instead of copying the parent's
+- Fixed: `$model->url()` read `translatable.default_redirect` instead of `default_redirect_route`
+- **Blade components on javaabu/forms v2** (Onu and Bootstrap 6): ``, `textarea` and `field` (an input per locale, in tabs or stacked, with direction, language and the Thaana keyboard), `language-switcher`, `translation-status`, `translation-notice` (adding a DB translation), and the rebuilt `titles` / `table-cells`
+- `Translatable::translationUrl()` and `Translatable::switchUrl()`
+- The `styles` config is gone: the components follow javaabu/forms' framework
+- **Inertia React components**, imported as `@javaabu/translatable` through javaabu/manifest: `TranslatableProvider`, `TranslatableField`/`Text`/`Textarea`, `LanguageSwitcher`, `TranslationStatus`, `TranslationHeadings`/`TranslationCells`, `TranslationNotice`, with `en`/`dv` strings through @javaabu/i18n
+- The React components' npm packages are declared for javaabu/manifest's `pages` scope, so `manifest:check` doesn't ask Blade-only apps to install React packages
+- With `translatable.views` set to `inertia`, the languages are shared with every Inertia page as the `translatable` prop
+- Docs rewritten for people and agents (`docs/`, `llms.txt`), with an upgrade guide from v2; Laravel Boost guideline and skill; `AGENTS.md` for contributors
+- Every string in English and Dhivehi, with the PHP and React lines kept identical by a test
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..43c994c
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
diff --git a/LICENSE b/LICENSE
deleted file mode 100644
index e3e4a82..0000000
--- a/LICENSE
+++ /dev/null
@@ -1,21 +0,0 @@
-MIT License
-
-Copyright (c) 2024 Javaabu
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
diff --git a/LICENSE.md b/LICENSE.md
index 8834892..4104bba 100644
--- a/LICENSE.md
+++ b/LICENSE.md
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
\ No newline at end of file
+SOFTWARE.
diff --git a/README.md b/README.md
index 77a6ac4..9271000 100644
--- a/README.md
+++ b/README.md
@@ -1,102 +1,58 @@
# Translatable
-[](https://packagist.org/packages/javaabu/translatable)
-[](../../actions/workflows/run-tests.yml)
-[](https://packagist.org/packages/javaabu/translatable)
-
+[](https://github.com/n4ff4h/translatable/actions/workflows/tests.yml?query=branch%3Anext-gen)
+[](https://codecov.io/gh/n4ff4h/translatable/tree/next-gen)
+[](https://packagist.org/packages/javaabu/translatable)
+[](LICENSE.md)
-
-Adds multi-lingual to Laravel models
-
-## Installation
-
-You can install the package via composer:
-
-```bash
-composer require javaabu/translatable
-```
-
-
-## Usage
-
-To get started with this package, you can simply add `$table->dbTranslatable();` or `$table->jsonTranslatable();` to your migration `up` function.
-
-```php
-use Illuminate\Database\Migrations\Migration;
-use Illuminate\Database\Schema\Blueprint;
-use Illuminate\Support\Facades\Schema;
-use Javaabu\Translatable\DbTranslatable\DbTranslatableSchema;
-
-return new class extends Migration {
- public function up(): void
- {
- Schema::create('posts', function (Blueprint $table) {
- $table->id();
-
- // ...
-
- $table->dbTranslatable();
- // OR
- $table->jsonTranslatable();
- });
- }
-
- public function down(): void
- {
- Schema::dropIfExists('posts');
- }
-};
-```
-
-And then all you need to do is add the `Translatable` implementation using the `IsDbTranslatable` or `IsJsonTranslatable` trait.
+Multilingual Eloquent models for Laravel. Attributes are read in the current locale, every locale is readable and writable as `title_dv`, and translations are stored as JSON on the row or as a row per locale. Ships Blade components ([javaabu/forms](https://github.com/Javaabu/forms) v2) and Inertia React components ([@javaabu/forms](https://github.com/Javaabu/react-forms)), with Dhivehi and RTL built in.
```php
-...
-
-class Post extends Model implements Translatable
+class Post extends Model implements JsonTranslatable
{
- use IsDbTranslatable;
- // OR
use IsJsonTranslatable;
-...
-```
+ protected array $translatable = ['title', 'body'];
+}
-Now, your models will automatically be translated according to the current `app()->getLocale()`.
-
-```php
-app()->setLocale('en');
-$post->title // This is an English title
+$post = Post::create(['title_en' => 'Welcome', 'title_dv' => 'މަރުޙަބާ']);
app()->setLocale('dv');
-$post->title // Mee dhivehi title eh
+$post->title; // މަރުޙަބާ
+$post->title_en; // Welcome
+```
-$post->title_en // This is an English title
-$post->title_dv // Mee dhivehi title eh
+```blade
+
+
+
```
-To add different translations, all you need to do is
+## Installation
-```php
-// to add title for dv language
-$post->title_dv = "Mee Dhivehi title eh";
+```bash
+composer require javaabu/translatable
+php artisan vendor:publish --tag="translatable-migrations"
+php artisan migrate
+php artisan translatable:add-languages en dv
```
+Upgrading from v2? See [docs/upgrading.md](docs/upgrading.md).
+
## Documentation
-You'll find the documentation on [https://docs.javaabu.com/translatable](https://docs.javaabu.com/translatable).
+You'll find the documentation on [https://docs.javaabu.com/translatable](https://docs.javaabu.com/translatable), and in [`docs/`](docs/intro.md). Package authors adding optional translation support: [docs/advanced-usage/for-package-authors.md](docs/advanced-usage/for-package-authors.md). AI assistants: [llms.txt](llms.txt).
Find yourself stuck using the package? Found a bug? Do you have general questions or suggestions for improving this package? Feel free to create an [issue](../../issues) on GitHub, we'll try to address it as soon as possible.
If you've found a bug regarding security please mail [info@javaabu.com](mailto:info@javaabu.com) instead of using the issue tracker.
-
## Testing
-You can run the tests with
-
-``` bash
-./vendor/bin/phpunit
+```bash
+composer test
+composer lint
+pnpm install && pnpm check
```
## Changelog
diff --git a/clover.xml b/clover.xml
deleted file mode 100644
index 46506ab..0000000
--- a/clover.xml
+++ /dev/null
@@ -1,895 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/composer.json b/composer.json
index 7e5e964..c254c0d 100644
--- a/composer.json
+++ b/composer.json
@@ -1,12 +1,17 @@
{
"name": "javaabu/translatable",
- "description": "Adds multi-lingual to Laravel models",
+ "description": "Multi-lingual Eloquent models for Laravel, stored as rows per locale or as JSON, with Blade and Inertia React components",
"homepage": "https://github.com/Javaabu/translatable",
"license": "MIT",
"keywords": [
"javaabu",
"laravel",
- "translatable"
+ "translatable",
+ "translations",
+ "multilingual",
+ "i18n",
+ "dhivehi",
+ "rtl"
],
"authors": [
{
@@ -26,17 +31,27 @@
}
],
"require": {
- "php": "^8.2",
- "illuminate/support": "^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0",
- "javaabu/helpers": "^1.70"
- },
- "suggest": {
- "javaabu/forms": "For the little buttons and table cells that are provided."
+ "php": "^8.4",
+ "illuminate/database": "^12.0 || ^13.0",
+ "illuminate/support": "^12.0 || ^13.0"
},
"require-dev": {
- "laravel/pint": "^1.14",
- "orchestra/testbench": "^7.0 || ^8.0 || ^9.16 || ^10.0 || ^11.0",
- "phpunit/phpunit": "^9.5 || ^10.5 || ^11.5.3 || ^12.5.8 || ^13.0.3"
+ "inertiajs/inertia-laravel": "^2.0 || ^3.0",
+ "javaabu/boost-javaabu-guidelines": "^1.4",
+ "javaabu/forms": "^2.0@dev",
+ "javaabu/helpers": "^1.74",
+ "javaabu/manifest": "^1.0@dev",
+ "laravel/pint": "^1.25",
+ "orchestra/testbench": "^10.0 || ^11.0",
+ "phpunit/phpunit": "^11.5.3 || ^12.5.8",
+ "symfony/css-selector": "^7.2 || ^8.0",
+ "symfony/dom-crawler": "^7.2 || ^8.0"
+ },
+ "suggest": {
+ "javaabu/forms": "Required for the Blade components (^2.0)",
+ "javaabu/manifest": "Ships the Inertia React components to your app (^1.0)",
+ "inertiajs/inertia-laravel": "Shares the languages with Inertia pages when translatable.views is inertia (^2.0)",
+ "javaabu/helpers": "Use Javaabu\\Translatable\\Models\\AdminLanguage as the language model to get helpers' AdminModel (^1.74)"
},
"autoload": {
"psr-4": {
@@ -51,9 +66,15 @@
"Javaabu\\Translatable\\Tests\\": "tests/"
}
},
+ "scripts": {
+ "test": "vendor/bin/phpunit",
+ "test-coverage": "vendor/bin/phpunit --coverage-clover=clover.xml",
+ "lint": "vendor/bin/pint --test",
+ "format": "vendor/bin/pint"
+ },
"extra": {
"branch-alias": {
- "dev-main": "2.0-dev"
+ "dev-next-gen": "3.x-dev"
},
"laravel": {
"providers": [
@@ -61,6 +82,21 @@
]
}
},
+ "config": {
+ "sort-packages": true
+ },
"minimum-stability": "dev",
- "prefer-stable": true
+ "prefer-stable": true,
+ "repositories": [
+ {
+ "type": "vcs",
+ "url": "https://github.com/Javaabu/forms",
+ "no-api": true
+ },
+ {
+ "type": "vcs",
+ "url": "https://github.com/Javaabu/manifest",
+ "no-api": true
+ }
+ ]
}
diff --git a/config/translatable.php b/config/translatable.php
index c4821cf..4856800 100644
--- a/config/translatable.php
+++ b/config/translatable.php
@@ -1,10 +1,24 @@
Javaabu\Translatable\Models\Language::class,
+ /*
+ * Turn translations off without uninstalling the package. Other packages
+ * check this through Translatable::enabled().
+ */
+ 'enabled' => env('TRANSLATABLE_ENABLED', true),
+
+ /*
+ * The model for the languages table. Extend Javaabu\Translatable\Models\Language,
+ * or use Javaabu\Translatable\Models\AdminLanguage if your app has javaabu/helpers
+ * and wants the language to be a helpers AdminModel.
+ */
+ 'language_model' => Language::class,
- // Standard fields to be ignored for translation on all models
+ /*
+ * Columns that are never translated, on every model
+ */
'fields_ignored_for_translation' => [
'id',
'lang',
@@ -13,28 +27,50 @@
'deleted_at',
],
- // Whether attr_dv should fall back to app locale if translations do not exist
- 'lang_suffix_should_fallback' => false,
+ /*
+ * Whether suffixed attributes (title_dv) fall back to the model's own
+ * value when the translation is missing
+ */
+ 'lang_suffix_should_fallback' => false,
- // Default locale before translations are added
- 'default_locale' => 'en',
+ /*
+ * The default translation locale: the language of a model's own columns
+ * when it has no `lang`, and the first language in the views
+ */
+ 'default_locale' => 'en',
- // Cache configuration for the Language Registrar
- 'cache' => [
- 'expiration_time' => DateInterval::createFromDateString('24 hours'),
- 'key' => 'translation.languages.cache',
- 'driver' => 'default',
- ],
+ /*
+ * Locales whose free-text inputs get the Thaana keyboard in the views
+ */
+ 'thaana_locales' => ['dv'],
- 'styles' => [
- 'table-cell-wrapper' => 'd-flex justify-content-center align-items-center',
+ /*
+ * Which views the app uses: 'blade' (javaabu/forms) or 'inertia'
+ * (React with @javaabu/forms, shipped through javaabu/manifest)
+ */
+ 'views' => env('TRANSLATABLE_VIEWS', 'blade'),
- 'icons' => [
- 'add' => 'fas fa-plus',
- 'edit' => 'fas fa-edit',
- ],
+ /*
+ * With 'inertia' views, the languages are shared with every page as this prop
+ */
+ 'inertia' => [
+ 'share' => true,
+ 'prop' => 'translatable',
+ ],
+
+ /*
+ * Cache for the active languages
+ */
+ 'cache' => [
+ 'expiration_time' => DateInterval::createFromDateString('24 hours'),
+ 'key' => 'translation.languages.cache',
+ 'driver' => 'default',
],
- 'default_portal' => 'web',
+ /*
+ * Used by $model->url() when a DB translatable model has no translation in
+ * the requested locale on the default portal
+ */
+ 'default_portal' => 'web',
'default_redirect_route' => 'home',
];
diff --git a/database/migrations/add_local_name_to_languages_table.php b/database/migrations/add_local_name_to_languages_table.php
new file mode 100644
index 0000000..7fd8510
--- /dev/null
+++ b/database/migrations/add_local_name_to_languages_table.php
@@ -0,0 +1,28 @@
+string('local_name')->nullable()->after('name');
+ });
+ }
+
+ public function down(): void
+ {
+ // The column belongs to create_languages_table on new installs, so it stays
+ }
+};
diff --git a/database/migrations/create_languages_table.php b/database/migrations/create_languages_table.php
index 5270578..d2681ca 100644
--- a/database/migrations/create_languages_table.php
+++ b/database/migrations/create_languages_table.php
@@ -14,6 +14,7 @@ public function up(): void
$table->string('code');
$table->string('locale');
$table->string('name');
+ $table->string('local_name')->nullable();
$table->string('flag');
$table->boolean('is_rtl');
$table->boolean('active');
diff --git a/docs/advanced-usage/api-reference.md b/docs/advanced-usage/api-reference.md
new file mode 100644
index 0000000..bcf0d62
--- /dev/null
+++ b/docs/advanced-usage/api-reference.md
@@ -0,0 +1,76 @@
+---
+title: API reference
+sidebar_position: 5
+---
+
+# `Javaabu\Translatable\Translatable`
+
+Static; also through the `Javaabu\Translatable\Facades\Translatable` facade. The contract for other packages, stable within v3. See [For package authors](./for-package-authors.md).
+
+| Method | Returns |
+| --- | --- |
+| `enabled()` | `bool`: `translatable.enabled` and at least one active language |
+| `supports(object\|string\|null $model, ?string $field = null)` | `bool` |
+| `isDbTranslatable(object\|string\|null $model)` | `bool` |
+| `locales()` | `Collection`, the default first |
+| `locale(?string $code = null)` | `?Locale` (default: the current one) |
+| `defaultLocale()`, `currentLocale()` | `string` |
+| `primaryLocale(object\|string\|null $model = null)` | `string` |
+| `inputName(string $field, string $locale)` | `string` |
+| `inputs(string $field, object\|string\|null $model = null)` | `Collection` |
+| `rules(object\|string\|null $model, array $rules, bool $require_all = false)` | `array` |
+| `values(?Model $model, ?array $fields = null)` | `array` |
+| `status(?Model $model, ?Closure $url = null)` | `array` |
+| `translationUrl(?Model $model, string $locale, ?bool $translated = null)` | `?string` |
+| `switchUrl(string $locale)` | `string` |
+| `shared()` | `array{enabled, current, default, direction, locales}` |
+| `getAllowedTranslationLocales()`, `isAllowedTranslationLocale($locale)` | v2 instance methods |
+
+# `Locale` and `LocaleInput`
+
+| `Locale` | |
+| --- | --- |
+| `code`, `locale`, `name`, `native_name`, `direction`, `is_default`, `flag_url` | readonly properties |
+| `isRtl()`, `isThaana()` | `bool` |
+| `toArray()` | `{code, locale, name, native_name, direction, rtl, thaana, default, flag_url}` |
+
+| `LocaleInput` | |
+| --- | --- |
+| `field`, `name`, `locale` (a `Locale`), `is_primary` | readonly properties |
+| `label(?string $label = null)` | "Title (ދިވެހި)" |
+| `toArray()` | `{field, name, locale, direction, thaana, primary, language}` |
+
+# Model methods (both traits)
+
+| Method | |
+| --- | --- |
+| `getTranslatables()` | The `$translatable` property, unless overridden |
+| `isTranslatable(string $field)` | |
+| `translate(string $field, ?string $locale = null, bool $fallback = true)` | |
+| `hasTranslation(?string $locale = null)` | |
+| `addTranslation(string $locale, string $field, mixed $value)` | Saves |
+| `addTranslations(string $locale, array $fields)` | Saves |
+| `deleteTranslation(string $locale)`, `deleteTranslations()`, `clearTranslations(?string $locale = null)` | |
+| `primaryLocale()`, `getDefaultTranslationLocale()`, `isDefaultTranslationLocale(string $locale)` | |
+| `getAllowedTranslationLocales()`, `isAllowedTranslationLocale(string $locale)` | |
+| `getFillableTranslatables()` | `title_en`, `title_dv`, … |
+| `getFieldAndLocale(string $key)` | `['title', 'dv']` for `title_dv` |
+| `suffixTranslatables(array $attributes)` | |
+| `getNonTranslatables()`, `getAllNonTranslatables()`, `getNonTranslatablePivots()`, `getFieldsIgnoredForTranslation()` | |
+| `isFormFieldDisabled(string $field)` | Whether a non-translatable field should be read-only in a translation's form |
+| `url(string $action = 'show', $locale = null, ?string $portal = null)` | |
+| `locale_direction` attribute | `ltr` / `rtl` |
+
+DB models also have `translations()`, `childTranslations()`, `defaultTranslation()`, `getTranslation(?string $locale)`, `isDefaultTranslation()`, `isRootTranslation()`, `rootTranslations()` scope, `canUpdateTranslatableParent()`, `getPendingTranslations()` and `savePendingTranslations()`.
+
+# `Languages` facade
+
+`all()`, `allExceptCurrent()`, `except($codes)`, `get(?string $code)`, `has(?string $code)`, `default()`, `getDirection(?string $code)`, `isRtl(?string $code)`, `currentLanguageCode()`, `setCurrentLocale(string $locale, bool $set_app_locale = true)`, `defaultLanguageCode()`, `defaultAppLocale()`, `setToTranslationLocale()`, `setToAppLocale()`, `isCurrent($code)`, `isDefault($code = null)`.
+
+# Schema macros
+
+`$table->jsonTranslatable()`, `$table->dropJsonTranslatable()`, `$table->dbTranslatable()`, `$table->dropDbTranslatable()`.
+
+# Command
+
+`php artisan translatable:add-languages {codes?*} {--inactive}`
diff --git a/docs/advanced-usage/configuration.md b/docs/advanced-usage/configuration.md
new file mode 100644
index 0000000..a290aae
--- /dev/null
+++ b/docs/advanced-usage/configuration.md
@@ -0,0 +1,23 @@
+---
+title: Configuration
+sidebar_position: 2
+---
+
+Everything has a default; publish the file only to change one (`php artisan vendor:publish --tag="translatable-config"`).
+
+| Key | Default | What |
+| --- | --- | --- |
+| `enabled` | `env('TRANSLATABLE_ENABLED', true)` | Turns translations off without uninstalling. Other packages check it through `Translatable::enabled()`. |
+| `language_model` | `Javaabu\Translatable\Models\Language` | The languages table model. Extend the default one, or use `Javaabu\Translatable\Models\AdminLanguage` to get javaabu/helpers' `AdminModel` (needs javaabu/helpers). |
+| `fields_ignored_for_translation` | `id`, `lang`, `created_at`, `updated_at`, `deleted_at` | Never translated, on every model |
+| `lang_suffix_should_fallback` | `false` | Whether `$post->title_dv` falls back to the model's own value when missing |
+| `default_locale` | `en` | The language of rows without a `lang`, the middleware's fallback, and the first language in the views. Must be an active language. |
+| `thaana_locales` | `['dv']` | Locales whose inputs get the Thaana keyboard |
+| `views` | `env('TRANSLATABLE_VIEWS', 'blade')` | `blade` or `inertia`. With `inertia`, the languages are shared with Inertia pages. |
+| `inertia.share` | `true` | Share the languages with Inertia pages (when `views` is `inertia`) |
+| `inertia.prop` | `translatable` | The prop name |
+| `cache.expiration_time` | 24 hours | How long the languages list is cached |
+| `cache.key` | `translation.languages.cache` | |
+| `cache.driver` | `default` | A cache store name, or `default` for the app's store |
+| `default_portal` | `web` | For `$model->url()` (see [Locales and URLs](../basic-usage/locales-and-urls.md#model-urls)) |
+| `default_redirect_route` | `home` | Where `$model->url()` links when a DB model has no translation on the default portal |
diff --git a/docs/advanced-usage/for-package-authors.md b/docs/advanced-usage/for-package-authors.md
new file mode 100644
index 0000000..03fe07c
--- /dev/null
+++ b/docs/advanced-usage/for-package-authors.md
@@ -0,0 +1,296 @@
+---
+title: For package authors
+sidebar_position: 1
+---
+
+Many Javaabu packages (permissions, settings, notifications, locations, meta, menus, widgets, form-builder, post-types, cms, …) let apps translate their models **when javaabu/translatable is installed**, and work exactly as before when it isn't. This page is the contract they build on. Everything here is covered by semver: it doesn't change within v3.
+
+The example is a `javaabu/locations` package whose `Island` names can be translated (`Malé` / `މާލެ`).
+
+# composer.json
+
+Translatable is optional: suggest it, and install it for your tests.
+
+```json
+{
+ "suggest": {
+ "javaabu/translatable": "Translate island names (^3.0)"
+ },
+ "require-dev": {
+ "javaabu/translatable": "^3.0@dev"
+ },
+ "repositories": [
+ { "type": "vcs", "url": "https://github.com/n4ff4h/translatable" }
+ ]
+}
+```
+
+Drop the repository once v3 is on Packagist.
+
+# Detecting translatable
+
+Never call a translatable class without checking that it exists. Put the check in one place in your package:
+
+```php
+namespace Javaabu\Locations\Support;
+
+use Javaabu\Translatable\Translatable;
+
+class Translations
+{
+ public static function enabled(): bool
+ {
+ return class_exists(Translatable::class) && Translatable::enabled();
+ }
+
+ public static function supports(object|string|null $model, ?string $field = null): bool
+ {
+ return class_exists(Translatable::class) && Translatable::supports($model, $field);
+ }
+}
+```
+
+- `Translatable::enabled()` is `true` when `translatable.enabled` is on and at least one language is active.
+- `Translatable::supports($model, $field = null)` is `true` when it's enabled, the model (an instance or a class name) implements `Javaabu\Translatable\Contracts\Translatable`, and the field (if given) is one of its translatable fields.
+- `$model instanceof \Javaabu\Translatable\Contracts\Translatable` is safe even when the package isn't installed: PHP returns `false` for an unknown class.
+
+# Making your model translatable
+
+Translations are opt-in per model. Use the **JSON** storage for package models: it adds two columns and keeps one row per record, so ids, routes and relations don't change.
+
+## 1. Add the columns
+
+Your migration runs whether or not translatable is installed, so add the columns yourself, both nullable:
+
+```php
+Schema::create('islands', function (Blueprint $table) {
+ $table->id();
+ $table->string('name');
+ // ...
+
+ // Translations (javaabu/translatable). Harmless without it.
+ $table->json('translations')->nullable();
+ $table->string('lang')->nullable()->index();
+
+ $table->timestamps();
+});
+```
+
+Rows saved before translatable was installed have no `lang`; they're read as the default locale (`translatable.default_locale`).
+
+## 2. Let the model be swapped
+
+Your package resolves its model class from config, as every next-gen package does:
+
+```php
+// config/locations.php
+'models' => [
+ 'island' => Javaabu\Locations\Models\Island::class,
+],
+```
+
+Then pick one of these. Both are fine; the first needs nothing from your package.
+
+### The app opts in
+
+The app extends your model, adds the trait, and points your config at it:
+
+```php
+namespace App\Models;
+
+use Javaabu\Locations\Models\Island as BaseIsland;
+use Javaabu\Translatable\Contracts\JsonTranslatable;
+use Javaabu\Translatable\JsonTranslatable\IsJsonTranslatable;
+
+class Island extends BaseIsland implements JsonTranslatable
+{
+ use IsJsonTranslatable;
+
+ protected array $translatable = ['name'];
+}
+```
+
+```php
+// config/locations.php
+'models' => ['island' => App\Models\Island::class],
+```
+
+### Your package ships the translatable model
+
+Put a subclass next to your model. PHP only loads it when it's referenced, so it's safe to ship even when translatable isn't installed:
+
+```php
+namespace Javaabu\Locations\Models\Translatable;
+
+use Javaabu\Locations\Models\Island as BaseIsland;
+use Javaabu\Translatable\Contracts\JsonTranslatable;
+use Javaabu\Translatable\JsonTranslatable\IsJsonTranslatable;
+
+class Island extends BaseIsland implements JsonTranslatable
+{
+ use IsJsonTranslatable;
+
+ protected array $translatable = ['name'];
+
+ // Keep morph types pointing at the base model
+ public function getMorphClass(): string
+ {
+ return BaseIsland::class;
+ }
+}
+```
+
+If the base model uses `HasFactory`, override `newFactory()` in the subclass to return the base model's factory, and register policies for both classes.
+
+Choose it when the app hasn't configured a model:
+
+```php
+// LocationsServiceProvider::register()
+$this->app->bind('locations.models.island', function () {
+ return config('locations.models.island')
+ ?? (Translations::enabled() ? Models\Translatable\Island::class : Models\Island::class);
+});
+```
+
+`Translations::enabled()` reads the languages table, so call it lazily (in a binding or when the model is first needed), not while providers are registering.
+
+# Forms
+
+A translatable field has one input per active locale, named `{field}_{locale}`, the default locale included: `name_en`, `name_dv`. Validation, old input, model binding and `fill()` all use these names.
+
+## Validation
+
+Expand your rules with `Translatable::rules()`. The model's primary locale keeps the rules you wrote; the others get `nullable` instead of `required`:
+
+```php
+public function rules(): array
+{
+ $rules = [
+ 'name' => ['required', 'string', 'max:255'],
+ 'atoll_id' => ['required', 'exists:atolls,id'],
+ ];
+
+ $island = $this->route('island') ?? config('locations.models.island');
+
+ if (Translations::supports($island)) {
+ $rules = Translatable::rules($island, $rules);
+ // ['name_en' => ['required', 'string', 'max:255'], 'name_dv' => ['nullable', 'string', 'max:255'], 'atoll_id' => [...]]
+ }
+
+ return $rules;
+}
+```
+
+Pass `require_all: true` to require every locale. Fields that aren't translatable pass through unchanged.
+
+## Saving
+
+`fill()`, `create()` and `update()` accept the per-locale names, and the translations are written when the model is saved:
+
+```php
+$island = Island::create($request->validated()); // name_en and name_dv
+$island->update($request->validated());
+```
+
+## Rendering (Blade)
+
+Use translatable's components when the field is translatable, and your usual component otherwise:
+
+```blade
+@if(\Javaabu\Locations\Support\Translations::supports($island, 'name'))
+
+@else
+
+@endif
+```
+
+`` renders one `` per locale (with `dir`, `lang` and the Thaana keyboard for Dhivehi), in tabs. See [Blade components](../basic-usage/blade-components.md).
+
+## Rendering (Inertia React)
+
+Send the inputs and values with the page; don't import translatable's React components from your package, because they only exist in apps that install it:
+
+```php
+return Inertia::render('locations::Islands/Edit', [
+ 'island' => $island,
+ 'translations' => Translations::supports($island) ? [
+ 'values' => Translatable::values($island), // ['name_en' => 'Malé', 'name_dv' => 'މާލެ']
+ 'inputs' => ['name' => Translatable::inputs('name', $island)->values()],
+ ] : null,
+]);
+```
+
+Each input serializes to `{ field, name, locale, direction, thaana, primary, language: { code, name, native_name, … } }`, enough to render one `@javaabu/forms` `` per locale with `dir`, `thaana` and a label. Apps that install translatable can use its `TranslatableText` component, which takes the same data. See [React components](../basic-usage/react-components.md).
+
+## Stencil fields
+
+javaabu/stencil fields call `->translatable()`. When `Translatable::supports($model, $field)` is true, the field expands into the per-locale inputs from `Translatable::inputs()`; otherwise it renders as a plain field. Both of stencil's drivers use the same data, so Blade and React forms match.
+
+# Reading translations
+
+| Call | Gives |
+| --- | --- |
+| `$island->name` | The name in the current locale, falling back to the model's own value |
+| `$island->name_dv` | The Dhivehi name, or `null` (see `lang_suffix_should_fallback`) |
+| `$island->translate('name', 'dv', fallback: false)` | The same, explicitly |
+| `Translatable::values($island)` | Every translatable field in every locale, keyed by input name |
+| `Translatable::status($island)` | Which locales the model has, for status badges |
+| `$island->toArray()` | Translatable fields in the current locale |
+
+For an API resource, merge `Translatable::values($island)` when your clients need every locale.
+
+# API reference
+
+All static, on `Javaabu\Translatable\Translatable`:
+
+| Method | Returns |
+| --- | --- |
+| `enabled()` | `bool` |
+| `supports(object\|string\|null $model, ?string $field = null)` | `bool` |
+| `isDbTranslatable(object\|string\|null $model)` | `bool`: the model keeps each locale in its own row |
+| `locales()` | `Collection`, the default first |
+| `locale(?string $code = null)` | `?Locale`, the current one by default |
+| `defaultLocale()` / `currentLocale()` | `string` |
+| `primaryLocale(object\|string\|null $model = null)` | `string`: the model's `lang`, or the current locale for a new model |
+| `inputName(string $field, string $locale)` | `string`: `name_dv` |
+| `inputs(string $field, object\|string\|null $model = null)` | `Collection` keyed by locale, the primary first |
+| `rules(object\|string\|null $model, array $rules, bool $require_all = false)` | `array` |
+| `values(?Model $model, ?array $fields = null)` | `array` |
+| `status(?Model $model, ?Closure $url = null)` | `array` of `{locale, translated, primary, url, language}` |
+| `shared()` | `{enabled, current, default, direction, locales}` for the front end |
+
+`Locale` has `code`, `locale` (the HTML `lang`), `name`, `native_name`, `direction`, `is_default`, `flag_url`, `isRtl()`, `isThaana()` and `toArray()`. `LocaleInput` has `field`, `name`, `locale` (a `Locale`), `is_primary`, `label(?string $label)` ("Name (ދިވެހި)") and `toArray()`.
+
+# Testing both ways
+
+Test with translatable on, and with it off:
+
+```php
+use Javaabu\Translatable\Testing\InteractsWithLanguages;
+
+class IslandTranslationsTest extends TestCase
+{
+ use InteractsWithLanguages;
+ use RefreshDatabase;
+
+ #[Test]
+ public function it_saves_the_dhivehi_name(): void
+ {
+ $this->setUpLanguages(['en', 'dv']); // creates the languages table if needed, adds the languages
+
+ $this->post('/islands', ['name_en' => 'Malé', 'name_dv' => 'މާލެ', 'atoll_id' => $atoll->id]);
+
+ $this->assertEquals('މާލެ', Island::first()->name_dv);
+ }
+
+ #[Test]
+ public function it_works_without_translations(): void
+ {
+ $this->withoutTranslatable(function () {
+ $this->post('/islands', ['name' => 'Malé', 'atoll_id' => $atoll->id])->assertRedirect();
+ });
+ }
+}
+```
+
+To be sure nothing references translatable when it's missing, add a CI leg that removes it before running the tests that don't need it: `composer remove --dev javaabu/translatable --no-update && composer update`.
diff --git a/docs/advanced-usage/testing.md b/docs/advanced-usage/testing.md
new file mode 100644
index 0000000..e9d2378
--- /dev/null
+++ b/docs/advanced-usage/testing.md
@@ -0,0 +1,48 @@
+---
+title: Testing
+sidebar_position: 4
+---
+
+`Javaabu\Translatable\Testing\InteractsWithLanguages` sets up languages in tests, in apps and in packages:
+
+```php
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Javaabu\Translatable\Testing\InteractsWithLanguages;
+
+class PostsTest extends TestCase
+{
+ use InteractsWithLanguages;
+ use RefreshDatabase;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->setUpLanguages(['en', 'dv']);
+ }
+
+ #[Test]
+ public function it_saves_the_dhivehi_title(): void
+ {
+ $this->post('/en/posts', ['title_en' => 'Welcome', 'title_dv' => 'މަރުޙަބާ', 'slug' => 'welcome', 'body_en' => 'Hello'])
+ ->assertRedirect();
+
+ $this->assertEquals('މަރުޙަބާ', Post::first()->title_dv);
+ }
+
+ #[Test]
+ public function it_works_with_translations_off(): void
+ {
+ $this->withoutTranslatable(function () {
+ $this->post('/en/posts', ['title' => 'Welcome', 'slug' => 'welcome', 'body' => 'Hello'])->assertRedirect();
+ });
+ }
+}
+```
+
+| Method | What |
+| --- | --- |
+| `setUpLanguages(array $codes = ['en', 'dv'], ?string $current = null)` | Creates the languages table if it's missing, adds the languages from the presets, clears the cache, and sets the current locale (the first code by default) |
+| `withoutTranslatable(callable $callback)` | Runs the callback with `translatable.enabled` off |
+
+Use Thaana in tests of anything a user sees.
diff --git a/docs/advanced-usage/translations-and-rtl.md b/docs/advanced-usage/translations-and-rtl.md
new file mode 100644
index 0000000..ad3039a
--- /dev/null
+++ b/docs/advanced-usage/translations-and-rtl.md
@@ -0,0 +1,46 @@
+---
+title: Dhivehi, RTL and the package's strings
+sidebar_position: 3
+---
+
+# The package's strings
+
+Every string the views show is in `lang/en/strings.php` and `lang/dv/strings.php` (`translatable::strings.*`), and bundled for React in `resources/js/locales`. A test keeps the PHP and JS lines identical.
+
+Override a line in your app:
+
+```php
+// lang/vendor/translatable/dv/strings.php
+return [
+ 'translation_status' => 'ބަސްތައް',
+];
+```
+
+For React, load your lines into @javaabu/i18n (for example with javaabu/js-localization); lines under `translatable::strings.*` win over the bundled ones.
+
+# Right to left
+
+- Every language has a direction (`is_rtl`). Inputs get their language's `dir` and `lang`, so a Dhivehi input is right to left on an English page and an English input is left to right on a Dhivehi page.
+- The views use logical layout only (`gap`, `start`/`end`); nothing is placed left or right.
+- `Languages::getDirection()`, `locale_direction()` and `$model->locale_direction` give the direction for your layout's ``.
+
+# Thaana
+
+Dhivehi inputs (`translatable.thaana_locales`) get `dir="rtl" lang="dv" data-thaana`, which javaabu/forms' script turns into the Thaana keyboard: typing on a Latin keyboard enters Thaana (phonetic layout). In React, the inputs get `@javaabu/forms`' `thaana` prop.
+
+# Dhivehi lines to review
+
+These were translated by the package authors and should be checked by a Dhivehi speaker:
+
+| Key | English | Dhivehi |
+| --- | --- | --- |
+| `language` | Language | ބަސް |
+| `translation_status` | Translations | ތަރުޖަމާތައް |
+| `original_in` | Original: :locale | އަސްލު: :locale |
+| `translated_in` | Translated into :locale | :locale އަށް ތަރުޖަމާކުރެވިފައި |
+| `not_translated_in` | Not translated into :locale | :locale އަށް ތަރުޖަމާކުރެވިފައެއް ނުވޭ |
+| `add_translation_in` | Add :locale translation | :locale ތަރުޖަމާ އިތުރުކުރުން |
+| `edit_translation_in` | Edit :locale translation | :locale ތަރުޖަމާ ބަދަލުކުރުން |
+| `adding_translation` | You are adding the :locale translation of “:title”. | “:title” ގެ :locale ތަރުޖަމާ އިތުރުކުރަނީ. |
+
+`field_in_locale` (":label (:locale)") is the same in both.
diff --git a/docs/ai-assistants.md b/docs/ai-assistants.md
new file mode 100644
index 0000000..5d3c10a
--- /dev/null
+++ b/docs/ai-assistants.md
@@ -0,0 +1,34 @@
+---
+title: AI assistants
+sidebar_position: 1.35
+---
+
+The docs are plain Markdown, written for people and AI coding assistants alike. The package ships them, so they always match the version you have installed.
+
+| File | What |
+| --- | --- |
+| `vendor/javaabu/translatable/llms.txt` | The rules for using the package, and every page |
+| `vendor/javaabu/translatable/docs/` | The pages |
+| `vendor/javaabu/translatable/resources/boost/` | The Laravel Boost skill and guideline |
+
+# Laravel Boost
+
+[Laravel Boost](https://github.com/laravel/boost) finds the skill and guideline by itself in Composer packages. Run this after installing or updating the package:
+
+```bash
+php artisan boost:update
+```
+
+Choose `javaabu/translatable` if Boost asks which third-party packages to use.
+
+# Other assistants
+
+Point your assistant at the docs from the project's instructions file (`AGENTS.md`, `CLAUDE.md`, `.cursor/rules`, `.github/copilot-instructions.md`):
+
+```md
+## Translations
+
+Multilingual models use javaabu/translatable. Before making a model translatable or building a form for one, read vendor/javaabu/translatable/llms.txt.
+```
+
+Or copy `vendor/javaabu/translatable/resources/boost/skills/javaabu-translatable` into your assistant's skills folder, such as `.claude/skills/`.
diff --git a/docs/basic-usage/adding-languages.md b/docs/basic-usage/adding-languages.md
deleted file mode 100644
index 96112ac..0000000
--- a/docs/basic-usage/adding-languages.md
+++ /dev/null
@@ -1,63 +0,0 @@
----
-title: Adding Languages
-description: How to add and remove locales allowed for translations
-sidebar_position: 10
----
-
-Translatables will only work once allowed languages are added.
-
-# Adding an allowed language
-
-To add a language that is allowed to translate to, just simply add to the model that is used in `language_model` in your `config/translatable.php` file. The code below will work if the `language_model` is kept as it's default value.
-
-```php
-use Javaabu\Translatable\Models\Language;
-
-...
-
-Language::create([
- 'name' => 'English',
- 'code' => 'en',
- 'locale' => 'en',
- 'flag' => '🇬🇧',
- 'is_rtl' => false,
- 'active' => true,
-]);
-```
-
-The `code` field will be used internally while the `locale` field will be what is set to the html `lang` attribute. The `is_rtl` attribute controls whether this language is meant to be shown right-to-left.
-
-:::warning
-
-If your `code` contains an underscore (`_`), using the lang suffixes (`attr_en`) may not work as expected, please use a `-` if necessary.
-
-:::
-
-:::danger
-
-When adding allowed languages, please note that by default, the `default_locale` will not be allowed as no `language_model` record exists for it. Ensure you have added the `default_locale` language for translatables to work as intended.
-
-:::
-
-# Deleting an allowed language
-
-To soft-delete a language, you can simply set the `active` to false.
-
-```php
-$lang_en = \Javaabu\Translatable\Facades\Languages::get('en');
-
-$lang_en->active = false;
-$lang_en->save();
-```
-
-To completely remove a language, you can delete the language record.
-
-:::warning
-
-Note that this does not delete the existing language records on a translatable model.
-
-:::
-
-```php
-\Javaabu\Translatable\Facades\Languages::get('en')->delete();
-```
diff --git a/docs/basic-usage/adding-translations.md b/docs/basic-usage/adding-translations.md
deleted file mode 100644
index 7484633..0000000
--- a/docs/basic-usage/adding-translations.md
+++ /dev/null
@@ -1,37 +0,0 @@
----
-title: Adding Translations
-description: How to add translations for your Translatable models
-sidebar_position: 30
----
-
-Translations can be added using the `addTranslation` / `addTranslations` methods available on models with Translatables.
-
-```php
-// add translation for one attribute
-$post->addTranslation('dv', 'title', 'Mee dhivehi title eh');
-$post->addTranslation('dv', 'body', 'Mee dhivehi liyumeh');
-
-// add translations for multiple attributes at once
-$post->addTranslations('dv', [
- 'title' => 'Mee dhivehi title eh',
- 'body' => 'Mee dhivehi liyumeh'
-]);
-
-$post->title_dv // Mee dhivehi title eh
-$post->body_dv // Mee dhivehi liyumeh
-```
-
----
-
-Translations can also be added via the setters.
-
-```php
-$post->title_dv = "Mee dhivehi title eh";
-$post->body_dv = "Mee dhivehi liyumeh";
-
-app()->setLocale('dv');
-$post->title // Mee dhivehi title eh
-$post->body // Mee dhivehi liyumeh
-```
-
-> If adding translations give an error, make sure the locale is allowed in `allowed_translation_locales` in `config/translatable.php`. Check out [Installation and Setup > Publishing the config file](../installation-and-setup.md#publishing-the-config-file) for information on how to setup your config file.
diff --git a/docs/basic-usage/blade-components.md b/docs/basic-usage/blade-components.md
new file mode 100644
index 0000000..ce1c165
--- /dev/null
+++ b/docs/basic-usage/blade-components.md
@@ -0,0 +1,94 @@
+---
+title: Blade components
+description: Per-locale fields, language switcher, translation status, table cells and the add-translation notice
+sidebar_position: 5
+---
+
+The components are built on [javaabu/forms](https://github.com/Javaabu/forms) v2, so install it (`composer require javaabu/forms:^2.0`) and they follow its framework (`forms.framework`: `onu` or `bootstrap-6`). Every one has a [React twin](./react-components.md).
+
+They need no npm packages from translatable: its React dependencies (`@javaabu/forms`, `@javaabu/i18n`, React) are only for Inertia apps, so `php artisan manifest:check` doesn't ask a Blade app for them.
+
+# Per-locale fields
+
+```blade
+
+
+
+
+
+```
+
+Renders one javaabu/forms input per active language (`title_en`, `title_dv`), in tabs titled with each language's own name:
+
+- The model's primary locale comes first. `required` only applies to it (add `require-all` for every language).
+- Values come from the bound model (`$post->title_dv`), old input and errors as usual. The first tab with an error opens.
+- Each input gets its language's `dir` and `lang`; Dhivehi inputs get the Thaana keyboard (`translatable.thaana_locales`).
+- Labels are "Title (ދިވެހި)".
+- `layout="stacked"` shows the inputs one after another instead of in tabs.
+- Other attributes (`rows`, `placeholder`, `help`, …) are passed to every input; `class` and `id` go on the wrapper.
+- When the model or field isn't translatable, or translations are off, it renders a plain ``.
+
+| Attribute | Default | |
+| --- | --- | --- |
+| `name` | required | The field |
+| `type` (`field` only) | `text` | Any javaabu/forms input: `text`, `textarea`, `email`, `wysiwyg`, … |
+| `label` | generated | |
+| `model` | the form's model | |
+| `required` | `false` | Required in the primary locale |
+| `require-all` | `false` | Required in every locale |
+| `layout` | `tabs` | `tabs` or `stacked` |
+| `framework` | `forms.framework` | |
+
+# Language switcher
+
+```blade
+
+
+```
+
+A link per active language, titled with its own name, the current one marked. It links to the current page in that language ([`Translatable::switchUrl()`](./locales-and-urls.md#helpers)), or to the model's page with `:model`. Hidden with fewer than two languages.
+
+# Translation status
+
+```blade
+
+
+```
+
+A badge per language: translated or not, linking to edit the translation or add it (the create page with `lang_parent` for DB models). Links come from `$model->url()`; without matching routes the badges show without links.
+
+# Table headings and cells
+
+```blade
+
+
+
+
+
+
+ @foreach($posts as $post)
+
+
+
+
+ @endforeach
+
+```
+
+A column per language except the current one, with an edit (pencil) or add (plus) link per row.
+
+# Adding a DB translation
+
+```blade
+
+```
+
+"You are adding the ދިވެހި translation of “Welcome”.", with hidden `lang` and `lang_parent` inputs. Renders nothing without a parent. `locale` (default: the current one) and `title-attribute` (default: `title`, then `name`) are optional. See [Forms](./forms-and-validation.md#adding-a-translation-of-a-db-model).
+
+# Customizing
+
+```bash
+php artisan vendor:publish --tag="translatable-views"
+```
+
+Copies the views to `resources/views/vendor/translatable/common`. Add `resources/views/vendor/translatable/{framework}/{view}.blade.php` to change one framework only.
diff --git a/docs/basic-usage/difference-isdbtranslatable-isjsontranslatable.md b/docs/basic-usage/difference-isdbtranslatable-isjsontranslatable.md
deleted file mode 100644
index 8c31075..0000000
--- a/docs/basic-usage/difference-isdbtranslatable-isjsontranslatable.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-title: Difference between DB and JSON translatable
-description: The "But why..." of DB and JSON translatables
-sidebar_position: 70
----
-
-Functionally, both `IsDbTranslatable` and `IsJsonTranslatable` both implement all functions of `Translatable`, there are no differences in syntax when using either of these.
-
-But there are a few notable differences in how each type implements `Translatables`. Depending on the use case and scale of the application, one may make more sense than the other.
-
-| IsDbTranslatable | IsJsonTranslatable |
-|---------------------------------------------------------|-------------------------------------------------------------|
-| - Works by creating additional rows for each language | - Works by adding a `translatables` JSON field to every row |
-| - Additional queries required to check if locale exists | - No additional queries needed for translating |
-| - Indexable by default | - Needs to search through JSON field |
diff --git a/docs/basic-usage/fetching-translations.md b/docs/basic-usage/fetching-translations.md
deleted file mode 100644
index e761e75..0000000
--- a/docs/basic-usage/fetching-translations.md
+++ /dev/null
@@ -1,31 +0,0 @@
----
-title: Fetching Translations
-description: Ways to retrieve translations from Translatables
-sidebar_position: 20
----
-
-There are multiple ways to use locale translated attributes within your code.
-
-## Using application locale
-
-If you use `app()->setLocale('dv');` then translatables will automatically detect this and give you the translated attribute if available.
-
-```php
-app()->setLocale('dv');
-
-// ...
-
-// assuming you have added the translation
-$post->title // Mee dhivehi title eh
-```
-
-## Using language code suffixes
-
-If you need a specific locale, you can use the language code suffix to always get that locale.
-
-```php
-$post->title_dv // Mee dhivehi title eh
-$post->title_ar // null
-```
-
-By default, the language code suffix does not fallback to default locale. To change this behaviour, change the `lang_suffix_should_fallback` to true in the [config](../installation-and-setup.md#publishing-the-config-file) file.
diff --git a/docs/basic-usage/forms-and-validation.md b/docs/basic-usage/forms-and-validation.md
new file mode 100644
index 0000000..4bd89e4
--- /dev/null
+++ b/docs/basic-usage/forms-and-validation.md
@@ -0,0 +1,119 @@
+---
+title: Forms and validation
+description: Per-locale inputs, validation rules, saving, and binding values
+sidebar_position: 4
+---
+
+A translatable field has **one input per active language**, named `{field}_{locale}`, the default language included: `title_en`, `title_dv`. Validation, old input, model binding and `fill()` all use these names.
+
+# Validation
+
+`Translatable::rules()` expands your rules for the model's translatable fields. The model's primary locale keeps the rules you wrote; the others get `nullable` in place of `required`:
+
+```php
+use Javaabu\Translatable\Translatable;
+
+class PostRequest extends FormRequest
+{
+ public function rules(): array
+ {
+ return Translatable::rules($this->route('post') ?? Post::class, [
+ 'title' => ['required', 'string', 'max:255'],
+ 'body' => ['required', 'string'],
+ 'slug' => ['required', 'alpha_dash'],
+ ]);
+ }
+}
+// title_en: required|string|max:255, title_dv: nullable|string|max:255, body_en, body_dv, slug
+```
+
+- Pass `require_all: true` to require every language.
+- Rules can be arrays, `|` strings or rule objects.
+- Fields that aren't translatable, and every field when translations are off, pass through unchanged.
+- The primary locale is the model's `lang`; for a class name (a create form), the current locale.
+
+# Saving
+
+```php
+public function store(PostRequest $request)
+{
+ $post = Post::create($request->validated());
+
+ return redirect()->to($post->url('show'));
+}
+
+public function update(PostRequest $request, Post $post)
+{
+ $post->update($request->validated());
+
+ return redirect()->to($post->url('show'));
+}
+```
+
+# The form
+
+Blade:
+
+```blade
+
+
+
+
+
+
+```
+
+React (Inertia), with `Translatable::values($post)` merged into the model you pass:
+
+```tsx
+
+```
+
+See [Blade components](./blade-components.md) and [React components](./react-components.md).
+
+# Adding a translation of a DB model
+
+DB models have a row per language, so a translation is created from a form of its own, in the target locale, with the original's id as `lang_parent`. The [status badges and table cells](./blade-components.md#translation-status) link there.
+
+```php
+public function create(Request $request)
+{
+ $lang_parent = $request->query('lang_parent') ? Post::findOrFail($request->query('lang_parent')) : null;
+
+ return view('posts.create', compact('lang_parent'));
+}
+
+// The translation's form has the plain fields, in the locale being added
+public function storeTranslation(Request $request)
+{
+ $post = new Post($request->validate([
+ 'title' => ['required', 'string', 'max:255'],
+ 'body' => ['required', 'string'],
+ 'slug' => ['required', 'alpha_dash'],
+ ]));
+
+ if ($parent = Post::find($request->input('lang_parent'))) {
+ $post->translatable_parent_id = $parent->id;
+ $post->lang = $request->input('lang');
+ }
+
+ $post->save();
+
+ return redirect()->to($post->url('show'));
+}
+```
+
+```blade
+
+
+
+ ...
+
+```
+
+`translate_old('title', $lang_parent)` gives the parent's value as the starting value.
diff --git a/docs/basic-usage/languages.md b/docs/basic-usage/languages.md
new file mode 100644
index 0000000..6666d9e
--- /dev/null
+++ b/docs/basic-usage/languages.md
@@ -0,0 +1,89 @@
+---
+title: Languages
+description: Adding, switching off and reading the languages models can be translated into
+sidebar_position: 1
+---
+
+The languages models can be translated into are rows of the `languages` table. Only active ones count.
+
+# Adding languages
+
+```bash
+php artisan translatable:add-languages en dv ar
+php artisan translatable:add-languages fr --inactive
+```
+
+With no codes it adds the default locale and `dv`. Presets exist for `en`, `dv`, `ar`, `zh`, `ja`, `hi`, `ru`, `de`, `fr`, `it` and `es`. Languages that are already in the table are skipped.
+
+Or create them yourself (the model is `translatable.language_model`):
+
+```php
+use Javaabu\Translatable\Models\Language;
+
+Language::create([
+ 'name' => 'Dhivehi',
+ 'local_name' => 'ދިވެހި',
+ 'code' => 'dv',
+ 'locale' => 'dv',
+ 'flag' => 'mv',
+ 'is_rtl' => true,
+ 'active' => true,
+]);
+```
+
+| Column | What |
+| --- | --- |
+| `code` | Used everywhere in the package: suffixes (`title_dv`), the `lang` column, URLs. Don't use `_` in it. |
+| `locale` | The HTML `lang` attribute |
+| `name` | The English name |
+| `local_name` | The language's own name (ދިވެހި), shown by the language switcher and tabs. Falls back to `name`. |
+| `flag` | A flag code (`mv`); `$language->flag_url` gives `vendors/flags/mv.svg` (publish `translatable-flags`) |
+| `is_rtl` | Right-to-left |
+| `active` | Can be translated into |
+
+The list is cached (`translatable.cache`). Saving, deleting or restoring a language clears the cache.
+
+# Switching a language off
+
+```php
+use Javaabu\Translatable\Facades\Languages;
+
+Languages::get('ar')->update(['active' => false]);
+```
+
+Existing translations stay in the database; they just aren't read or offered any more. Deleting a language does the same.
+
+# Admin pages
+
+`Language` has `admin_url` (the `admin.languages.show` route), `admin_link_name` and a `search()` scope over `name`, `local_name`, `code`, `locale` and `flag`:
+
+```php
+Language::search($request->input('search'))->paginate();
+```
+
+If your admin pages or activity log need javaabu/helpers' `AdminModel` (`admin_link`, `logging_name`, `log_url`, …), install javaabu/helpers and use the `AdminLanguage` model:
+
+```php
+// config/translatable.php
+'language_model' => Javaabu\Translatable\Models\AdminLanguage::class,
+```
+
+translatable itself doesn't need helpers.
+
+# Reading the languages
+
+```php
+use Javaabu\Translatable\Facades\Languages;
+use Javaabu\Translatable\Translatable;
+
+Languages::all(); // active Language models
+Languages::get('dv'); // one, or null
+Languages::currentLanguageCode(); // the locale of this request
+Languages::isRtl('dv'); // true
+Languages::allExceptCurrent();
+
+Translatable::locales(); // Locale objects, the default first
+Translatable::locale('dv')->native_name; // ދިވެހި
+```
+
+`Translatable::locales()` gives `Locale` value objects (`code`, `locale`, `name`, `native_name`, `direction`, `is_default`, `flag_url`, `isRtl()`, `isThaana()`, `toArray()`), which is what the views and other packages use.
diff --git a/docs/basic-usage/locales-and-urls.md b/docs/basic-usage/locales-and-urls.md
new file mode 100644
index 0000000..ef74b02
--- /dev/null
+++ b/docs/basic-usage/locales-and-urls.md
@@ -0,0 +1,44 @@
+---
+title: Locales and URLs
+description: The language middleware, translated routes and model URLs
+sidebar_position: 7
+---
+
+# The `language` middleware
+
+```php
+Route::middleware(['web', 'language'])->prefix('{language}')->group(function () {
+ Route::get('posts/{post}', [PostsController::class, 'show'])->name('posts.show');
+});
+```
+
+It picks the locale from, in order: the `{language}` route parameter, a `language` input, the `?lang=` query, the session (not for API requests), then `translatable.default_locale`. It stores it in the session, and sets it as the current translation locale and the app locale. Use `language:0` to leave the app locale alone.
+
+`{language}` is bound to the `Language` model by its `locale`.
+
+# Helpers
+
+| Helper | What |
+| --- | --- |
+| `translation_locale()` | The current translation locale |
+| `translate_route('posts.show', $post, locale: 'dv')` | `route()` with the `language` parameter (default: the current locale) |
+| `translate_action([PostsController::class, 'show'], $post)` | `action()` with the `language` parameter |
+| `translate_url('posts/1')` | `/{locale}/posts/1` |
+| `locale_direction('dv')` | `rtl` |
+| `redirect()->translateRoute('posts.show', $post)` | A redirect to a translated route |
+| `ma($post, 'title')->translate('dv')` | An attribute wrapper |
+| `translate_old('title', $lang_parent)` | Old input, or the parent translation's value |
+| `_d('Save')` | `__()` in the fallback locale |
+
+`Translatable::switchUrl('dv')` gives the current page in another language: the current route with `{language}` swapped, or the current URL with `?lang=dv`.
+
+# Model URLs
+
+Translatable models have `url($action = 'show', $locale = null, $portal = null)`, used by the language switcher and the status links:
+
+```php
+$post->url(); // posts.show in the current locale
+$post->url('edit', 'dv'); // the dv translation's edit page
+```
+
+The route name is `{portal}.{models}.{action}` (portal from `request()->portal()`), or `{models}.{action}` without one. For a DB model with no translation in the locale, `edit` and `show` link to `create` with `lang_parent`, or, on `translatable.default_portal`, to `translatable.default_redirect_route`. Override `getModelRouteName()`, `getAdditionalRouteParams()` or `routeKeyForPortal()` in your model to change them.
diff --git a/docs/basic-usage/making-models-translatable.md b/docs/basic-usage/making-models-translatable.md
new file mode 100644
index 0000000..8c003c6
--- /dev/null
+++ b/docs/basic-usage/making-models-translatable.md
@@ -0,0 +1,89 @@
+---
+title: Making models translatable
+description: The JSON and DB storage strategies, and the model options
+sidebar_position: 2
+---
+
+# Choosing a strategy
+
+| | JSON (`IsJsonTranslatable`) | DB (`IsDbTranslatable`) |
+| --- | --- | --- |
+| Storage | The model's columns hold its primary language; `translations` holds the others | A row per language, linked by `translatable_parent_id` |
+| Columns | `$table->jsonTranslatable()`: `translations` (JSON), `lang` | `$table->dbTranslatable()`: `translatable_parent_id`, `lang` |
+| Reading a translation | No extra query | A query for the translation's row |
+| Each language has its own id, slug, URL | No | Yes |
+| Querying and indexing translated values | Through the JSON column | Like any column |
+| Package models (stable ids and relations) | Use this | |
+
+Both have the same API, so you can switch later with a data migration.
+
+# JSON
+
+```php
+use Illuminate\Database\Eloquent\Model;
+use Javaabu\Translatable\Contracts\JsonTranslatable;
+use Javaabu\Translatable\JsonTranslatable\IsJsonTranslatable;
+
+class Article extends Model implements JsonTranslatable
+{
+ use IsJsonTranslatable;
+
+ protected $fillable = ['title', 'body', 'slug', 'author_id'];
+
+ protected array $translatable = ['title', 'body'];
+}
+```
+
+The trait casts `translations` to an array, unless you cast it yourself.
+
+# DB
+
+```php
+use Illuminate\Database\Eloquent\Model;
+use Javaabu\Translatable\Contracts\DbTranslatable;
+use Javaabu\Translatable\DbTranslatable\IsDbTranslatable;
+
+class Post extends Model implements DbTranslatable
+{
+ use IsDbTranslatable;
+
+ protected $fillable = ['title', 'body', 'slug', 'author_id'];
+
+ protected array $translatable = ['title', 'body'];
+
+ public function getNonTranslatablePivots(): array
+ {
+ return ['author_id'];
+ }
+}
+```
+
+A translation row starts as a copy of the original's columns, so every column has a value. Only translatable fields differ between rows.
+
+```php
+$post->translations; // the other rows
+$post->defaultTranslation; // the original, from a translation row
+$post->getTranslation('dv'); // the row for a locale, or null
+$post->isDefaultTranslation(); // true for the original
+Post::rootTranslations()->get(); // originals only
+```
+
+# Options
+
+| Method or property | Default | What |
+| --- | --- | --- |
+| `protected array $translatable` | `[]` | The translatable fields. Or override `getTranslatables()`. |
+| `getNonTranslatablePivots()` | `[]` | Foreign keys shared by every translation (DB) |
+| `$fields_ignored_for_translation` | `id`, `lang`, and `translations` or `translatable_parent_id` | Never translated; merged with `translatable.fields_ignored_for_translation` |
+
+# The primary locale
+
+A model's `lang` is the language its own columns are in:
+
+- New models get the current locale (`translation_locale()`) when they're first written, unless you set `lang`.
+- Rows without a `lang` (columns added to a table that already had data) are read as `translatable.default_locale`, and get it when an attribute is next set on them.
+
+```php
+$article->primaryLocale(); // 'en'
+$article->locale_direction; // 'ltr'
+```
diff --git a/docs/basic-usage/react-components.md b/docs/basic-usage/react-components.md
new file mode 100644
index 0000000..1735247
--- /dev/null
+++ b/docs/basic-usage/react-components.md
@@ -0,0 +1,122 @@
+---
+title: React components
+description: The Inertia React components, shipped through javaabu/manifest
+sidebar_position: 6
+---
+
+The React components mirror the [Blade components](./blade-components.md). They're TypeScript in the package's `resources/js`, built by your app's Vite through [javaabu/manifest](https://github.com/Javaabu/manifest), and drawn with [@javaabu/forms](https://github.com/Javaabu/react-forms), so they follow your forms adapter (Onu, shadcn/ui, HTML).
+
+# Setup
+
+1. Install javaabu/manifest and add its Vite plugin ([its docs](https://github.com/Javaabu/manifest)). `@javaabu/translatable` then resolves to `vendor/javaabu/translatable/resources/js`; `php artisan manifest:types` adds the TypeScript paths.
+2. Install the npm packages it uses. `php artisan manifest:check` lists them once your `package.json` has `@inertiajs/react` (they're declared for manifest's `pages` scope, so Blade-only apps aren't asked for them; `--scope=pages` checks them anyway):
+
+ ```bash
+ pnpm add github:Javaabu/react-forms#next-gen github:Javaabu/i18n#build-on-git-install
+ ```
+
+3. Share the languages with every page:
+
+ ```dotenv
+ TRANSLATABLE_VIEWS=inertia
+ ```
+
+ With `translatable.views` set to `inertia`, `Translatable::shared()` is shared as the `translatable` prop (`translatable.inertia.prop`).
+
+4. Provide it in your layout:
+
+ ```tsx
+ import { usePage } from "@inertiajs/react"
+ import { TranslatableProvider, type TranslatableShared } from "@javaabu/translatable"
+
+ export default function Layout({ children }: { children: React.ReactNode }) {
+ const { translatable } = usePage<{ translatable: TranslatableShared }>().props
+
+ return {children}
+ }
+ ```
+
+Strings come in English and Dhivehi through [@javaabu/i18n](https://github.com/Javaabu/i18n), in its current locale. Lines your app loads under `translatable::strings.*` win.
+
+# Per-locale fields
+
+```tsx
+import { Form, Submit, Text } from "@javaabu/forms"
+import { TranslatableField, TranslatableText, TranslatableTextarea } from "@javaabu/translatable"
+
+// $post, plus Translatable::values($post) as `translations`
+
+```
+
+| Prop | Default | |
+| --- | --- | --- |
+| `name` | required | |
+| `type` (`TranslatableField`) | `text` | `textarea` or any `Input` type |
+| `label` | generated | |
+| `required` / `requireAll` | `false` | As in Blade |
+| `layout` | `tabs` | `tabs` or `stacked` |
+| `primary` | the current locale | The model's `lang` |
+| `inputs` | from the shared languages | `Translatable::inputs('title', $post)` from the server, to skip `primary` |
+
+Other props go to every input. Without a provider, or with translations off, it renders a plain field.
+
+# Language switcher
+
+```tsx
+
+ `/${locale.code}/posts/${post.id}`} />
+```
+
+The default link is the current URL with `?lang=`, which the `language` middleware reads. For routes with a `{language}` parameter, pass `url` (with Wayfinder or your route helper).
+
+# Translation status, table headings and cells
+
+```php
+return Inertia::render('Posts/Index', [
+ 'posts' => $posts->map(fn (Post $post) => [
+ ...$post->toArray(),
+ 'translation_status' => Translatable::status($post, fn (Locale $locale, bool $translated) => Translatable::translationUrl($post, $locale->code, $translated)),
+ ]),
+]);
+```
+
+```tsx
+
+
+