Skip to main content
Quote blocks are the reusable service units that clients select in the quote builder at /cotizador. Each block belongs to a category, carries a base price and default hours estimate, and can hold an arbitrary list of key-value extras stored in its config field.

Data model

Categories

Categories group related blocks in the builder’s sidebar. A category must exist before blocks can be assigned to it.
FieldTypeNotes
idbigintAuto-incremented primary key
namestringUnique display name
descriptiontextOptional longer description shown in the UI
iconstringOptional icon identifier displayed in the builder sidebar
colorstringOptional colour (hex or CSS class) used to distinguish the category in the UI
orderintegerSort position (ascending). Auto-assigned on creation
is_activebooleanDefaults to true. Inactive categories are hidden
created_attimestamp
updated_attimestamp

Blocks

Each block represents one billable service line. Blocks are nested under a category.
FieldTypeNotes
idbigintAuto-incremented primary key
namestringBlock display name
descriptiontextOptional description shown in the builder card
category_idbigint (FK)References quote_block_categories.id — cascades on delete
base_pricedecimal(10,2)Base price for one unit of the block
default_hoursintegerEstimated hours of work for this block
configjsonArray of key-value extras (see below)
formulatextReserved for future pricing formula support
validation_rulesjsonReserved for future input validation
is_activebooleanDefaults to true. Only active blocks appear in the builder
orderintegerSort position within the category (ascending)
created_attimestamp
updated_attimestamp

The config field

The config column stores an array of single-key objects. Each entry maps one string key to a string or numeric value.
[
  { "revision_rounds": 3 },
  { "delivery_format": "figma" },
  { "rush_multiplier": 1.5 }
]
These extras are created through the admin form’s dynamic “extras” input rows and processed by processConfig() in QuoteBlockController before being stored.

How processConfig works

processConfig is a private method on QuoteBlockController that converts the raw form input into the structured array stored in config.
private function processConfig(?array $extras): array
{
    if (empty($extras)) {
        return [];
    }

    $config = [];

    foreach ($extras as $item) {
        if (empty($item['key']) || !array_key_exists('value', $item)) {
            continue; // skip incomplete rows
        }

        $config[] = [
            $item['key'] => is_numeric($item['value'])
                ? $item['value'] + 0  // cast to int or float
                : $item['value'],     // keep as string
        ];
    }

    return $config;
}
Key behaviours:
  • Rows with an empty key are silently skipped.
  • Numeric values are automatically cast ("3"3, "1.5"1.5).
  • String values are stored as-is.
  • Passing no extras (or an empty array) returns [], which stores as an empty JSON array.

Managing blocks via the admin UI

The admin panel for quote blocks is available at /bloques (authentication required).

Create a category

Submit the New Category form on the /bloques index page. The name field must be unique across all categories. The order value is automatically set to max(order) + 1.

Create a block

Click New Block and select a parent category. Fill in the name, base price, default hours, and any extra config rows. The block’s order is automatically set to max(order) + 1 within that category.

Edit a block

Navigate to /bloques/{id}/edit. All fields including extras can be updated. The order field is not shown in the edit form — use drag-and-drop reordering instead.

Reorder blocks

The index page supports drag-and-drop reordering via POST /bloques/reorder (JSON body: {"blocks": [{"id": 1, "order": 0}, ...]}). Each block’s order is updated individually.

How blocks appear in the builder

The quote builder SPA fetches blocks from GET /api/quote-blocks. The controller applies two scopes before returning data:
  1. active() — filters to is_active = true on both categories and blocks.
  2. ordered() — sorts by order ASC on both categories and blocks.
// From QuoteController::builder()
$categories = QuoteBlockCategory::with([
    'blocks' => function ($query) {
        $query->where('is_active', true)->orderBy('order');
    }
])->orderBy('order')->get();
Blocks where is_active = false are never sent to the client — they are invisible in the builder and cannot be added to quotes.

Eloquent relationships

// QuoteBlockCategory
public function blocks(): HasMany
{
    return $this->hasMany(QuoteBlock::class, 'category_id');
}

// QuoteBlock
public function category(): BelongsTo
{
    return $this->belongsTo(QuoteBlockCategory::class, 'category_id');
}

Validation rules on store and update

The QuoteBlockController applies the following rules when creating or updating a block:
FieldRule
nameRequired, string, max 255 characters
category_idRequired, must exist in quote_block_categories
base_priceRequired, numeric, min 0
default_hoursRequired, integer, min 0
descriptionOptional
is_activeOptional boolean (defaults to true)
For categories:
FieldRule
nameRequired, string, max 255, unique in quote_block_categories
descriptionOptional, max 500 characters

Model scopes

Both QuoteBlock and QuoteBlockCategory define reusable local scopes:
// Filter to active records
$query->active();       // WHERE is_active = 1

// Sort by order column
$query->ordered();      // ORDER BY order ASC

// Filter blocks by category (QuoteBlock only)
$query->byCategory(3); // WHERE category_id = 3

Build docs developers (and LLMs) love