upon / mlang
High-performance multi-language support for Laravel with security enhancements, bulk operations, and intuitive API. Efficiently manage translations with minimal database overhead.
Installs: 888
Dependents: 0
Suggesters: 0
Security: 0
Stars: 2
Watchers: 1
Forks: 1
Open Issues: 1
pkg:composer/upon/mlang
Requires
- php: ^8.3
- ext-filter: *
- illuminate/database: ^v11.44.7|12.*
- illuminate/support: ^v11.44.7|12.*
Requires (Dev)
- phpunit/phpunit: ^10.0|^11.0
- dev-main
- v2.1.1
- v2.1.0
- v2.0.2
- v2.0.1
- v2.0.0
- v1.0.12
- v1.0.11
- v1.0.10
- v1.0.9
- v1.0.8
- v1.0.7
- 1.0.6
- 1.0.5
- 1.0.4
- v1.0.3
- v1.0.2
- v1.0.1
- v1.0
- dev-feature/laravel_11
- dev-issue/fixing_ulid_migration
- dev-ML1-T1
- dev-feature/auto_discover_provider
- dev-ML1-T2
- dev-feature/merging_migration_with_default_laravel_command
This package is auto-updated.
Last update: 2025-12-15 08:55:55 UTC
README
Stop using JSON columns. Start using proper database structure for translations.
Quick Start • Why MLang? • Features • Documentation • Contributing
Why Another Translation Package?
Most Laravel translation packages store translations in JSON columns. This works, but has serious limitations:
- Can't query translations efficiently - Want to search products by name in French? Good luck with JSON.
- No database indexing - JSON columns can't be indexed properly, making searches slow.
- Complex migrations - Adding a new translatable field means updating JSON structure.
MLang takes a different approach: Each translation is a proper database row with row_id (linking translations together) and iso (language code). This means:
// Find all products with "laptop" in the name - in ANY language Product::where('name', 'like', '%laptop%')->get(); // Works with standard Laravel queries, indexes, and full-text search!
Quick Start
composer require upon/mlang
php artisan vendor:publish --tag="mlang"
php artisan mlang:migrate
// Add to your model use Upon\Mlang\Contracts\MlangContractInterface; use Upon\Mlang\Models\Traits\MlangTrait; class Product extends Model implements MlangContractInterface { use MlangTrait; } // Create product in multiple languages at once MLang::forModel(Product::class)->createMultiLanguage( attributes: ['price' => 99.99], languages: ['en', 'fr', 'de'], translatedAttributes: [ 'en' => ['name' => 'Laptop', 'description' => 'Powerful laptop'], 'fr' => ['name' => 'Ordinateur', 'description' => 'Ordinateur puissant'], 'de' => ['name' => 'Laptop', 'description' => 'Leistungsstarker Laptop'], ] ); // Query in current language - automatically filtered! $products = Product::trWhere('status', 'active')->get();
Why MLang?
| Feature | MLang | JSON-based packages |
|---|---|---|
| Database queries on translations | Native SQL queries | Requires JSON functions |
| Index support | Full index support | Limited/None |
| Full-text search | Native support | Complex workarounds |
| Bulk operations | Built-in methods | Manual implementation |
| Translation statistics | getStats(), getCoverage() |
Not available |
| Multi-language insert | Single method call | Multiple inserts |
| Rate limiting | Built-in protection | Not available |
| Security helpers | Input validation & sanitization | Varies |
When to Use MLang
Choose MLang if you need:
- Search/filter by translated fields (e-commerce, CMS, catalogs)
- Database-level performance for translations
- Translation coverage analytics
- Bulk translation operations
Stick with JSON-based packages if:
- You only display translations (no querying)
- You have very few translatable models
- You prefer simpler table structure
Documentation
- Version Compatibility
- Installation
- Features
- Database Structure
- Artisan Commands
- Using the Facade
- Query Usage
- Helper Classes
- Configuration Options
- Security Best Practices
- Contributing
- License
Features
Core Features
| Feature | Description |
|---|---|
| Row-based translations | Each translation is a database row - queryable, indexable, searchable |
| Auto language scoping | Queries automatically filter by current locale |
| Route model binding | Works seamlessly with Laravel's route model binding |
| Fallback support | Automatic fallback to default language when translation missing |
| Browser detection | Middleware to detect user's preferred language |
Developer Experience
| Feature | Description |
|---|---|
| One-line multi-language insert | createMultiLanguage() creates all translations at once |
| Bulk operations | Update/delete all translations for a record in one call |
| Translation statistics | getStats() and getCoverage() for analytics |
| Artisan commands | mlang:migrate, mlang:generate for easy management |
| Facade API | Fluent interface: MLang::forModel(Product::class)->... |
Security & Performance
| Feature | Description |
|---|---|
| Input validation | Built-in validation for locales, table names, model classes |
| SQL injection prevention | All queries use Laravel Query Builder |
| Rate limiting | Built-in protection for bulk operations |
| Safe with factories | Works before migrations run |
Installation
Requirements
| PHP | Laravel |
|---|---|
| >= 8.1 | 10.x, 11.x, 12.x |
Step 1: Install
composer require upon/mlang composer require doctrine/dbal # Required dependency php artisan vendor:publish --tag="mlang"
Step 2: Configure Models
// config/mlang.php 'models' => [ \App\Models\Category::class, \App\Models\Product::class, ]
Step 3: Add Trait to Models
use Upon\Mlang\Contracts\MlangContractInterface; use Upon\Mlang\Models\Traits\MlangTrait; class Product extends Model implements MlangContractInterface { use MlangTrait; }
Alternative: You can also extend
Upon\Mlang\Models\MlangModelif you prefer inheritance over traits.
Step 4: Run Migration
php artisan mlang:migrate
This adds two columns to your tables:
row_id- Links translations together (same row_id = same content in different languages)iso- Language code (en, fr, de, etc.)
Artisan Commands
The package provides several Artisan commands for managing translations:
| Command | Arguments | Options | Description |
|---|---|---|---|
mlang:migrate |
- | --table=TABLE_NAME--rollback |
Add MLang columns to tables Use --rollback to remove columns |
mlang:generate |
{model?}{locale?} |
- | Generate translations for models Optionally specify model name and locale |
Command Examples
# Migrate all configured models php artisan mlang:migrate # Migrate specific table php artisan mlang:migrate --table=categories # Rollback all migrations php artisan mlang:migrate --rollback # Rollback specific table php artisan mlang:migrate --table=categories --rollback # Generate translations for all models and languages php artisan mlang:generate # Generate for specific model php artisan mlang:generate Category # Generate for specific model and language php artisan mlang:generate Category fr # Generate for all models, specific language php artisan mlang:generate all fr
Using the Facade
MLang comes with a powerful facade that provides a fluent interface for interacting with the package.
Core Facade Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
forModel() |
object|string $model |
Mlang |
Set the model to work with (chainable) |
getModelName() |
- | string |
Get the current model name |
getTableName() |
- | string|null |
Get the table name for current model |
getTableNames() |
- | array |
Get all table names from configured models |
getModels() |
- | array |
Get all configured model class names |
getCurrentModel() |
- | string|null |
Get current model class name |
getModelInstance() |
- | object|null |
Get instance of current model |
Migration & Generation Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
migrate() |
?string $table = null |
Mlang |
Add MLang columns to table (chainable) |
rollback() |
?string $table = null |
Mlang |
Remove MLang columns from table (chainable) |
generate() |
?string $model = null, ?string $locale = null |
Mlang |
Generate translations for model (chainable) |
🆕 Multi-Language Operations
| Method | Parameters | Returns | Description |
|---|---|---|---|
createMultiLanguage() |
array $attributes, ?array $languages = null, ?array $translatedAttributes = null |
array |
Create record in multiple languages at once |
getAllTranslations() |
int|string $id |
Collection |
Get all language versions of a record by ID |
updateAllTranslations() |
int|string $id, array $attributes |
int |
Update all translations by ID (returns count) |
deleteAllTranslations() |
int|string $id |
int |
Delete all translations by ID (returns count) |
copyToLanguage() |
Model|int|string $sourceModelOrId, string $targetLanguage, array $overrideAttributes = [] |
Model|null |
Copy record to another language (accepts ID or model) |
🆕 Translation Statistics & Analysis
| Method | Parameters | Returns | Description |
|---|---|---|---|
getStats() |
- | array |
Get translation statistics (total, unique, per language) |
getCoverage() |
- | float |
Get translation coverage percentage |
getIncompleteTranslations() |
- | Collection |
Get records with missing translations |
Basic Usage Examples
use Upon\Mlang\Facades\MLang; use App\Models\Category; // Specify which model to work with MLang::forModel(Category::class)->migrate(); // Chain multiple operations together MLang::forModel(Category::class) ->migrate() ->generate(); // Get model information $modelName = MLang::forModel(Category::class)->getModelName(); // Returns "Category" $tableName = MLang::forModel(Category::class)->getTableName(); // Returns the table name // You can also use a model instance $category = new Category(); MLang::forModel($category)->generate();
🆕 Multi-Language Insert (NEW!)
Create a record in multiple languages simultaneously:
use Upon\Mlang\Facades\MLang; use App\Models\Product; // Create product in all configured languages $records = MLang::forModel(Product::class)->createMultiLanguage([ 'name' => 'Product Name', 'description' => 'Product Description', 'price' => 99.99 ]); // Create product in specific languages only $records = MLang::forModel(Product::class)->createMultiLanguage( attributes: [ 'name' => 'Product Name', 'price' => 99.99 ], languages: ['en', 'fr', 'de'] ); // Create with language-specific content $records = MLang::forModel(Product::class)->createMultiLanguage( attributes: [ 'price' => 99.99, 'status' => 'active' ], languages: ['en', 'fr'], translatedAttributes: [ 'en' => [ 'name' => 'English Product Name', 'description' => 'English Description' ], 'fr' => [ 'name' => 'Nom du Produit Français', 'description' => 'Description Française' ] ] );
🆕 Translation Statistics (NEW!)
Get insights into your translations:
use Upon\Mlang\Facades\MLang; use App\Models\Category; // Get translation statistics $stats = MLang::forModel(Category::class)->getStats(); // Returns: // [ // 'total_records' => 150, // 'unique_records' => 50, // 'languages' => [ // 'en' => 50, // 'fr' => 48, // 'de' => 45 // ] // ] // Get translation coverage percentage $coverage = MLang::forModel(Category::class)->getCoverage(); // Returns: 94.67 // Get records with incomplete translations $incomplete = MLang::forModel(Category::class)->getIncompleteTranslations();
🆕 Bulk Translation Operations (NEW!)
Manage all translations for a record using its regular ID - no need to know about row_id!
use Upon\Mlang\Facades\MLang; use App\Models\Product; // Get all translations for a specific product (pass the regular ID) $productId = 1; $translations = MLang::forModel(Product::class)->getAllTranslations($productId); // Returns collection of all language versions (English, French, German, etc.) // Update all translations at once (common fields like price, status) $updated = MLang::forModel(Product::class)->updateAllTranslations($productId, [ 'price' => 149.99, 'status' => 'sale' ]); // Updates the price and status for all language versions // Delete all translations for a record (all languages) $deleted = MLang::forModel(Product::class)->deleteAllTranslations($productId); // Copy a record to another language - multiple ways: // 1. Using model instance $product = Product::find(1); $frenchProduct = MLang::forModel(Product::class)->copyToLanguage($product, 'fr', [ 'name' => 'Nom du Produit Français' ]); // 2. Using just the ID (much easier!) $frenchProduct = MLang::forModel(Product::class)->copyToLanguage(1, 'fr', [ 'name' => 'Nom du Produit Français', 'description' => 'Description en Français' ]);
Configuration Options
The package offers various configuration options to fine-tune its behavior:
// config/mlang.php // Control automatic generation of translations 'auto_generate' => false, // Enable automatic migration after Laravel migrations 'auto_migrate' => false, // Control observer behavior during console operations 'observe_during_console' => false, // Auto-generate translations after seeding 'auto_generate_after_seed' => false, // Specify which models to process after migrations/seeding 'auto_generate_models' => 'all', // Control rollback behavior 'auto_rollback' => true, // Toggle debug output 'debug_output' => true,
Working with Factories
The package is designed to work safely with factories even before migrations have run. It intelligently detects when MLang columns don't exist and avoids trying to use them in such cases.
To ensure smooth operation with factories:
- Set
'auto_generate' => falsein your config file when using factories before migrations. - The trait will automatically detect missing columns and adjust behavior accordingly.
- After running migrations, you can enable auto-generation features.
Trait-based approach benefits: The trait-based approach makes it easy to work with factories before migrations, with enhanced column existence detection and graceful fallbacks.
Managing Translations
Using Artisan Commands
Generate translations for all models:
php artisan mlang:generate
Generate translations for a specific model:
php artisan mlang:generate {model}
# Example: php artisan mlang:generate Category
# Or for nested models: php artisan mlang:generate Shop\\Product
Generate for a specific language:
php artisan mlang:generate {model|all} {locale}
# Example: php artisan mlang:generate all fr
Remove a language from a table:
php artisan mlang:remove {table} {locale}
# Example: php artisan mlang:remove categories fr
Using the Facade
You can also manage translations programmatically using the facade:
use Upon\Mlang\Facades\Mlang; use App\Models\Category; // Generate translations for a specific model Mlang::forModel(Category::class)->generate(); // Generate translations for a specific model and language Mlang::generate('Category', 'fr'); // Run migrations for a specific model Mlang::forModel(Category::class)->migrate(); // Roll back migrations for a specific model Mlang::forModel(Category::class)->rollback();
Language Detection
To automatically detect the user's browser language:
- Add the locale middleware to your
app/Http/Kernel.phpfile:
protected $middlewareGroups = [ 'web' => [ // ... \Upon\Mlang\Middleware\DetectUserLanguageMiddleware::class, ], // ... ];
To manually set the language:
app()->setLocale('fr');
Query Usage
Both package versions provide the same query methods for working with multilingual content.
Model Query Scopes
| Scope Method | Parameters | Description | Example |
|---|---|---|---|
trFind() |
int|string $id, ?string $iso = null |
Find record by row_id in current (or specified) language | Category::trFind(1) |
trWhere() |
array|string|Closure $conditions |
Query with auto language filter and id→row_id mapping | Category::trWhere('status', 'active') |
Finding Records
// Find by row_id in current language $category = Category::trFind(1); // Find in specific language $category = Category::trFind(1, 'fr');
Querying with Conditions
// Query in current language $categories = Category::trWhere(['name' => 'test'])->get(); // Chain other query methods $categories = Category::trWhere('status', 'active') ->orderBy('created_at', 'desc') ->paginate(10); // Complex queries $categories = Category::trWhere(function($query) { $query->where('price', '>', 100) ->orWhere('featured', true); })->get();
Route Model Binding
The package enhances Laravel's route model binding to automatically fetch the correct language version:
// routes/web.php Route::get('/categories/{category}', function (Category $category) { // $category will be automatically fetched in the current application language return view('categories.show', compact('category')); });
How it works:
- The
row_idfrom the URL is used to find the record - The current application locale (
app()->getLocale()) determines the language - Returns 404 if no translation exists in the current language
Understanding Interface and Trait Relationship
When implementing MLang in your models, it's important to understand the relationship between the interface and trait:
-
The Interface (
MlangContractInterface) defines the contract that your models must fulfill to be MLang-compatible. -
The Trait (
MlangTrait) provides the actual implementation of the methods required by the interface.
You must use both together:
class Category extends Model implements MlangContractInterface { use MlangTrait; // Your model code... }
This provides several benefits:
- Contract Enforcement: Ensures all required methods are available
- Implementation Reuse: Reuses code through the trait
- Flexibility: Allows customization by overriding trait methods
- Type Safety: Provides better IDE support and static analysis
🆕 Helper Classes
The package includes organized helper classes for better code organization and reusability.
SecurityHelper Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
validateLocale() |
string $locale |
bool |
Validate locale format (throws exception if invalid) |
validateTableName() |
string $table |
bool |
Validate table name (throws exception if invalid) |
validateColumnName() |
string $column |
bool |
Validate column name (throws exception if invalid) |
validateModelClass() |
string $model |
bool |
Validate model class exists (throws exception if invalid) |
validateLocales() |
array $locales |
bool |
Validate multiple locales at once |
tableExists() |
string $table |
bool |
Check if table exists in database |
columnExists() |
string $table, string $column |
bool |
Check if column exists in table |
sanitizeValue() |
mixed $value |
mixed |
Remove null bytes and control characters |
sanitizeAttributes() |
array $attributes |
array |
Sanitize all values in array |
isValidRowId() |
mixed $rowId |
bool |
Check if value is valid row_id (positive integer) |
checkRateLimit() |
string $key, int $maxAttempts = 60, int $decayMinutes = 1 |
bool |
Rate limiting check for bulk operations |
Example Usage:
use Upon\Mlang\Helpers\SecurityHelper; // Validate inputs SecurityHelper::validateLocale('en'); SecurityHelper::validateTableName('users'); SecurityHelper::validateModelClass(Product::class); // Check database structure if (SecurityHelper::tableExists('categories')) { if (SecurityHelper::columnExists('categories', 'row_id')) { // Proceed with operation } } // Sanitize user inputs $clean = SecurityHelper::sanitizeValue($userInput); $cleanAttributes = SecurityHelper::sanitizeAttributes($request->all()); // Rate limiting if (SecurityHelper::checkRateLimit('bulk_operation', 100, 1)) { // Proceed with bulk operation }
LanguageHelper Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
getConfiguredLanguages() |
- | array |
Get all configured languages from config |
getFallbackLanguage() |
- | string |
Get fallback language from config |
getCurrentLocale() |
- | string |
Get current application locale |
isLanguageConfigured() |
string $locale |
bool |
Check if language is in configuration |
parseAcceptLanguageHeader() |
?string $header |
string |
Parse Accept-Language header to get best match |
validateAndGetLocale() |
?string $locale = null |
string |
Validate locale or return fallback |
getMissingLanguages() |
array $existingLanguages |
array |
Get languages not in existing array |
getLanguageName() |
string $locale |
string |
Get human-readable language name |
sortLanguagesByPriority() |
array $languages |
array |
Sort languages (current first, then config order) |
isAutoGenerateEnabled() |
- | bool |
Check if auto-generation is enabled |
shouldObserveDuringConsole() |
- | bool |
Check if observer runs during console |
getConfiguredModels() |
- | array |
Get all configured model classes |
Example Usage:
use Upon\Mlang\Helpers\LanguageHelper; // Get language configuration $languages = LanguageHelper::getConfiguredLanguages(); // ['en', 'fr', 'de'] $fallback = LanguageHelper::getFallbackLanguage(); // 'en' $current = LanguageHelper::getCurrentLocale(); // Current app locale // Check configuration if (LanguageHelper::isLanguageConfigured('fr')) { // French is configured } // Parse browser language $locale = LanguageHelper::parseAcceptLanguageHeader('fr-FR,fr;q=0.9,en;q=0.8'); // Get missing translations $missing = LanguageHelper::getMissingLanguages(['en', 'fr']); // ['de'] // Get language name $name = LanguageHelper::getLanguageName('fr'); // 'French'
TranslationHelper Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
getExistingTranslations() |
Model $model, int|string $rowId |
array |
Get array of existing locale codes for row_id |
createMultiLanguageRecord() |
Model $model, array $attributes, array $languages, ?array $translatedAttributes = null |
array |
Create record in multiple languages |
generateRowId() |
Model $model |
int |
Generate new unique row_id |
handleUniqueConstraints() |
Model $model, array $attributes, string $language |
array |
Handle unique constraints by appending suffixes |
getUniqueIndexes() |
string $table |
array |
Get unique indexes for table (DB-agnostic) |
copyToLanguage() |
Model $sourceModel, string $targetLanguage, array $overrideAttributes = [] |
Model|null |
Copy record to another language |
deleteAllTranslations() |
Model $model, int|string $rowId |
int |
Delete all translations for row_id |
updateAllTranslations() |
Model $model, int|string $rowId, array $attributes |
int |
Update all translations for row_id |
getTranslationStats() |
Model $model |
array |
Get statistics (total, unique, per language) |
Example Usage:
use Upon\Mlang\Helpers\TranslationHelper; // Get existing translations $existing = TranslationHelper::getExistingTranslations($model, $rowId); // Returns: ['en', 'fr'] if those exist // Create multi-language records $records = TranslationHelper::createMultiLanguageRecord( $model, ['name' => 'Product', 'price' => 99.99], ['en', 'fr', 'de'] ); // Copy to another language $newRecord = TranslationHelper::copyToLanguage($product, 'fr', [ 'name' => 'Nom Français' ]); // Bulk operations $count = TranslationHelper::updateAllTranslations($model, $rowId, ['price' => 149.99]); $deleted = TranslationHelper::deleteAllTranslations($model, $rowId); // Get statistics $stats = TranslationHelper::getTranslationStats($model); // Returns: ['total_records' => 150, 'unique_records' => 50, 'languages' => [...]]
QueryHelper Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
applyLanguageFilter() |
Builder $query, ?string $locale = null |
Builder |
Add language filter to query |
applyRowIdFilter() |
Builder $query, int|string $rowId |
Builder |
Add row_id filter to query |
getAllTranslations() |
Model $model, int|string $rowId |
Collection |
Get all language versions of record |
findByRowIdAndLocale() |
Model $model, int|string $rowId, ?string $locale = null |
Model|null |
Find specific translation |
getRecordsWithIncompleteTranslations() |
Model $model |
Collection |
Get records missing some translations |
scopeCurrentLanguage() |
Builder $query |
Builder |
Scope to current language only |
scopeWithCompleteTranslations() |
Builder $query |
Builder |
Scope to records with all translations |
buildMlangQuery() |
Model $model, array $conditions = [], ?string $locale = null |
Builder |
Build query with language awareness |
getTranslationCoverage() |
Model $model |
float |
Get coverage percentage (0-100) |
Example Usage:
use Upon\Mlang\Helpers\QueryHelper; // Apply filters to query $query = Product::query(); QueryHelper::applyLanguageFilter($query, 'fr'); QueryHelper::applyRowIdFilter($query, 123); $products = $query->get(); // Find specific translation $product = QueryHelper::findByRowIdAndLocale($model, $rowId, 'fr'); // Get all translations for a record $translations = QueryHelper::getAllTranslations($model, $rowId); // Get incomplete translations $incomplete = QueryHelper::getRecordsWithIncompleteTranslations($model); // Build MLang-aware query $query = QueryHelper::buildMlangQuery($model, ['status' => 'active'], 'fr'); // Get coverage percentage $coverage = QueryHelper::getTranslationCoverage($model); // e.g., 94.67
Security Best Practices
This package includes several security features:
- Input Validation: All user inputs (model names, table names, locales) are validated before use
- SQL Injection Prevention: Uses Laravel's Query Builder exclusively, no raw SQL with user input
- Sanitization: Automatic sanitization of string values to remove null bytes and control characters
- Rate Limiting: Built-in rate limiting for bulk operations to prevent abuse
- Type Safety: Strong typing throughout the codebase with PHP 8.3+ features
Security Guidelines
// ✅ GOOD: Using facade methods (automatically validated) MLang::forModel(Category::class)->migrate(); // ✅ GOOD: Validated locale MLang::forModel(Product::class)->generate(locale: 'fr'); // ❌ BAD: Don't use raw SQL with MLang operations DB::raw("..."); // Not recommended with user input // ✅ GOOD: Use sanitization for user inputs $attributes = SecurityHelper::sanitizeAttributes($request->all()); MLang::forModel(Product::class)->createMultiLanguage($attributes);
Contributing
Contributions are welcome! Here's how you can help:
- Star the repo - It helps others discover MLang
- Report bugs - Open an issue with reproduction steps
- Suggest features - We'd love to hear your ideas
- Submit PRs - Bug fixes and improvements welcome
Support the Project
If MLang helps you build multilingual Laravel apps, please consider:
- Giving it a star on GitHub
- Sharing it with other Laravel developers
- Writing about your experience using it
License
MIT License - see LICENSE for details.
Built with care by Charisma Design