Quick Form Creation with FormBuilder
This is a manual, artisanal system for creating autocomplete select fields. If you need to create forms quickly from your Models,
we recommend using the FormBuilder which can generate complete forms in minutes:
→ Getting Started - Forms with FormBuilder
MilkSelect - Dynamic Autocomplete Select
MilkSelect is a powerful autocomplete component that supports both static options and dynamic API-based loading. It's perfect for handling relationships and large datasets.
Basic Usage - Static Options
For small, static datasets, you can pass options directly:
Single Selection
Form::milkSelect('country', 'Country',
['IT' => 'Italy', 'FR' => 'France', 'DE' => 'Germany'],
'IT' // selected value
);
Multiple Selection
Form::milkSelect('tags', 'Tags',
['php' => 'PHP', 'js' => 'JavaScript', 'py' => 'Python'],
['php', 'js'], // selected values (array)
['type' => 'multiple']
);
Dynamic API Loading - The Power Feature
For large datasets or when you need search functionality, use API loading. This is the recommended approach for relationships.
Step-by-Step Implementation
Step 1: Configure the Model Field
In your model's configure() method, use apiUrl() to specify the API endpoint and display field:
// Example: CarsModel.php
class CarsModel extends AbstractModel
{
protected function configure($rule): void
{
$rule->table('#__cars')
->id()
->string('model', 100)->required()
->string('color', 50)
// Single selection with belongsTo
->int('manufacturer_id')
->belongsTo('manufacturer', ManufacturersModel::class, 'id')
->formType('milkSelect')
->apiUrl('?page=cars&action=search-manufacturers', 'name')
->required()
->error('Please select a manufacturer');
}
}
apiUrl()takes 2 parameters: the API endpoint and the field to displaybelongsTo()is required for automatic relationship handling- The field type should be
intfor single selection with belongsTo
Step 2: Create the Controller Action
Add an action in your Controller to handle the search requests:
// Example: CarsController.php
class CarsController extends AbstractController
{
#[RequestAction('search-manufacturers')]
public function searchManufacturers() {
$search = $_REQUEST['q'] ?? '';
$options = $this->model->searchRelated($search, 'manufacturer_id');
Response::json([
'success' => 'ok',
'options' => $options
]);
}
}
apiUrl(). The search term is passed as q parameter.
Step 3: Add Search Method to Model
Use the generic searchRelated() method that reads configuration from the relationship:
// Add to your Model (e.g., CarsModel.php)
/**
* Generic search for autocomplete based on relationship configuration
*/
public function searchRelated(string $search = '', string $field_name = 'manufacturer_id'): array
{
$rules = $this->getRules();
// Get the relationship configuration
if (!isset($rules[$field_name]['relationship']) ||
$rules[$field_name]['relationship']['type'] !== 'belongsTo') {
return [];
}
$relationship = $rules[$field_name]['relationship'];
$related_model_class = $relationship['related_model'];
$display_field = $rules[$field_name]['api_display_field'] ?? 'name';
$related_key = $relationship['related_key'] ?? 'id';
// Instantiate related model
$relatedModel = new $related_model_class();
$query = $relatedModel->query();
// Filter by search term on display field
if (!empty($search)) {
$query->where("$display_field LIKE ?", '%' . $search . '%');
}
$results = $query->limit(0, 20)->getResults();
$options = [];
foreach ($results as $result) {
$options[$result->$related_key] = $result->$display_field;
}
return $options;
}
- The related model class from
belongsTo() - The display field from
apiUrl() - The key field from the relationship
Multiple Selection WITHOUT belongsTo
For many-to-many relationships or when you don't need belongsTo, use a text field with multiple mode:
// In your Model
->text('category_ids')
->label('Categories')
->formType('milkSelect')
->apiUrl('?page=products&action=search-categories', 'name')
->formParams(['type' => 'multiple'])
->excludeFromDatabase(); // if it's not a real DB column
- Use
texttype instead ofint - No
belongsTo()relationship - Add
['type' => 'multiple']in formParams - Data is saved as JSON array:
["1","2","3"]
Complete Working Example
Here's a minimal but complete example you can use as a starting point:
1. Create the Models
ManufacturersModel.php
namespace Modules\Examples;
use App\Abstracts\AbstractModel;
class ManufacturersModel extends AbstractModel
{
protected function configure($rule): void
{
$rule->table('#__manufacturers')
->id()
->string('name', 100)->required()
->string('country', 50);
}
}
CarsModel.php
namespace Modules\Examples;
use App\Abstracts\AbstractModel;
class CarsModel extends AbstractModel
{
protected function configure($rule): void
{
$rule->table('#__cars')
->id()
->string('model', 100)->required()
->int('manufacturer_id')
->belongsTo('manufacturer', ManufacturersModel::class, 'id')
->formType('milkSelect')
->apiUrl('?page=examples-cars&action=search-manufacturers', 'name')
->required();
}
public function searchRelated(string $search = '', string $field_name = 'manufacturer_id'): array
{
$rules = $this->getRules();
if (!isset($rules[$field_name]['relationship']) ||
$rules[$field_name]['relationship']['type'] !== 'belongsTo') {
return [];
}
$relationship = $rules[$field_name]['relationship'];
$related_model_class = $relationship['related_model'];
$display_field = $rules[$field_name]['api_display_field'] ?? 'name';
$related_key = $relationship['related_key'] ?? 'id';
$relatedModel = new $related_model_class();
$query = $relatedModel->query();
if (!empty($search)) {
$query->where("$display_field LIKE ?", '%' . $search . '%');
}
$results = $query->limit(0, 20)->getResults();
$options = [];
foreach ($results as $result) {
$options[$result->$related_key] = $result->$display_field;
}
return $options;
}
}
2. Create the Controller
namespace Modules\Examples;
use App\{Response};
use App\Abstracts\AbstractController;
use App\Attributes\RequestAction;
use Builders\{TableBuilder, FormBuilder};
class CarsController extends AbstractController
{
#[RequestAction('home')]
public function carsList() {
$response = TableBuilder::create($this->model, 'idTableCars')
->column('manufacturer.name', 'Manufacturer')
->setDefaultActions()
->getResponse();
$response['page'] = $this->page;
$response['title'] = 'Cars Management';
Response::render(__DIR__ . '/views/cars_list.php', $response);
}
#[RequestAction('car-edit')]
public function carEdit() {
$response = ['page' => $this->page, 'title' => 'Edit Car'];
$response['form'] = FormBuilder::create($this->model)->getForm();
Response::render(__DIR__ . '/views/car_edit.php', $response);
}
#[RequestAction('search-manufacturers')]
public function searchManufacturers() {
$search = $_REQUEST['q'] ?? '';
$options = $this->model->searchRelated($search, 'manufacturer_id');
Response::json([
'success' => 'ok',
'options' => $options
]);
}
}
How It Works
- Initial Load: When editing an existing record, the system uses lazy loading to display the current value (e.g., "Toyota" instead of "1")
- User Types: After 300ms, a fetch request is sent to the API with the search term
- API Response: The server returns filtered options:
{"success":"ok", "options":{"1":"Toyota","2":"Honda"}} - Display: Options are shown in the dropdown
- Selection: The ID is saved in the database, but the name is displayed to the user
Configuration Options
Field Configuration (in Model)
| Method | Parameters | Description |
|---|---|---|
apiUrl() |
$url, $display_field | Set API endpoint and field to display |
belongsTo() |
$alias, $model, $key | Define relationship for automatic handling |
formType() |
'milkSelect' | Set field type to MilkSelect |
formParams() |
['type' => 'multiple'] | Enable multiple selection mode |
required() |
- | Make field required |
error() |
$message | Custom validation error message |
Form Options
| Option | Values | Description |
|---|---|---|
type |
'single', 'multiple' | Selection mode |
required |
true, false | Field validation |
class |
string | Additional CSS classes |
placeholder |
string | Placeholder text (not with floating) |
floating |
true, false | Enable floating label (default: true) |
invalid-feedback |
string | Custom error message |
API Response Format
Your API endpoint must return JSON in this format:
{
"success": "ok",
"options": {
"1": "Toyota",
"2": "Honda",
"3": "Ford"
}
}
Or for simple arrays without keys:
{
"success": "ok",
"options": ["Red", "Blue", "Green"]
}
"success" !== "ok" or fetch fails, a simple alert will be shown to the user.
Best Practices
- Use
apiUrl()for large datasets (> 50 items) - Use
belongsTo()with single selection for automatic relationship handling - Limit API results to 20-50 items for better performance
- Use the generic
searchRelated()method for consistency - Add indexes on search fields in your database
- Don't use
belongsTo()with array/text fields (only with int) - Don't return more than 100 results from the API
- Don't forget to sanitize the search parameter in your API
- Don't use static options for large datasets
Troubleshooting
Problem: Shows ID instead of name
Solution: Make sure you're using belongsTo() and the relationship is properly configured. The lazy loading will handle displaying the correct value.
Problem: API not called when typing
Solution: Check that:
- The
apiUrl()endpoint is correct - The router action exists and is accessible
- The browser console shows no JavaScript errors
Problem: No options shown
Solution: Verify the API response format matches the expected structure. Check the network tab in browser DevTools.
Advanced: Custom Search Logic
You can override searchRelated() for custom search logic:
public function searchManufacturers(string $search = ''): array
{
$model = new ManufacturersModel();
$query = $model->query();
if (!empty($search)) {
// Custom logic: search in name AND country
$query->where("(name LIKE ? OR country LIKE ?)",
'%' . $search . '%',
'%' . $search . '%'
);
}
$results = $query->orderBy('name ASC')->limit(0, 20)->getResults();
$options = [];
foreach ($results as $item) {
// Custom format: show both name and country
$options[$item->id] = $item->name . ' (' . $item->country . ')';
}
return $options;
}