Skip to content

Writes, Validation, And Batches

These examples extend the Project resource. Configure authentication and the corresponding controller and model permissions first. Set API_TOKEN locally to a valid access token. Examples show view fragments unless a complete envelope is labelled explicitly.

Choose Create, Update, Or Save

Action No identity Matching identity Unknown/hidden identity
create Creates; single success 201 Rejects with 400 Rejects with 400
update Rejects with 400 Updates; 200 Rejects with 404
save Creates; 200 Updates; 200 Can create a new row; 200

The normal identity is id; save intent also recognizes uuid. Lookup uses the model's identity-column configuration. A non-primary UUID column is not automatically a lookup key merely because the input is named uuid.

Choose /update when “this record must already exist” matters. /save is not a safe substitute for that requirement: an ID hidden by permission conditions can fail lookup and lead to creation. Save strips id/uuid from assigned data; it does not promise to preserve a supplied identifier on creation.

Create One Record

curl http://127.0.0.1:8080/api/project/create \
  -b cookies.txt -c cookies.txt \
  -H "X-Authorization: Bearer $API_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"label":"Playground","status":"draft","budget":500}'

HTTP 201, response: true:

{
  "saved": true,
  "mode": "create",
  "data": {"id": 4, "label": "Playground", "status": "draft", "budget": 500},
  "messages": []
}

Only fields allowed by initializeSaveFields() are assigned. Rejecting unknown scalar input is an application validation decision; an allowlist does not necessarily return an error for every discarded key.

Update A Record

curl -X PATCH http://127.0.0.1:8080/api/project/update \
  -b cookies.txt -c cookies.txt \
  -H "X-Authorization: Bearer $API_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"id":4,"status":"active"}'

HTTP 200, response: true, view.mode: "update"; view.data contains the updated exposed record. Omitted writable attributes retain their existing values. The generic PUT path also assigns supplied fields; it does not implement a full-resource replacement contract automatically.

Missing ID, HTTP 400:

{
  "saved": false,
  "messages": [
    {
      "field": "id",
      "message": "Missing identity fields for update.",
      "type": "InvalidUpdate",
      "code": 400,
      "metaData": {"fields": ["id"]}
    }
  ]
}

Validate Business Rules

Generated models validate schema constraints such as uniqueness and value lengths. Add business validation to the concrete model; do not edit its generated abstract. For example, a concrete model can append a message and return false from a cancellable validation/save hook:

/** Prevent activation before the project has a budget. */
public function beforeSave(): bool
{
    if ($this->getStatus() === 'active' && (int)$this->getBudget() <= 0) {
        $this->appendMessage(new \Phalcon\Messages\Message(
            'An active project needs a positive budget.',
            'budget',
            'BudgetRequired'
        ));
        return false;
    }
    return true;
}

Compose this with existing hooks if your application already implements them. Ordinary validation messages produce HTTP 422; a message carrying a 400–499 code can select that HTTP status. A failed persistence operation without messages falls back to 400. Failures do not include a saved data object or mode by default. Database exceptions are not guaranteed to become validation messages.

Save A Batch

A top-level JSON list invokes batch processing:

curl http://127.0.0.1:8080/api/project/create \
  -b cookies.txt -c cookies.txt \
  -H "X-Authorization: Bearer $API_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '[{"label":"New park","budget":600},{"label":"Community garden"}]'

On the tutorial database, the second label already exists. HTTP 207 has response: false and this view (generated ID varies):

{
  "saved": false,
  "messages": [{"type": "summary", "message": "1 of 2 entities were not saved."}],
  "results": [
    {
      "saved": true,
      "mode": "create",
      "data": {"id": 5, "label": "New park", "status": "draft", "budget": 600},
      "messages": []
    },
    {
      "saved": false,
      "messages": [{
        "field": "label",
        "message": "not-unique",
        "type": "Phalcon\\Filter\\Validation\\Validator\\Uniqueness",
        "code": 0,
        "metaData": []
      }]
    }
  ],
  "stats": {"total": 2, "saved": 1, "failed": 1}
}

Results correspond to input positions. Inspect each row, not only root messages. The root list contains a summary rather than all validation details.

Batch outcome HTTP response What persisted
All saved 200 true Every successful row
Some saved 207 false Successful rows remain saved
All rejected 422 false No row reported saved
Empty list 200 true Nothing; all stats are zero

An empty JSON object also decodes into PHP's empty array and reaches the empty batch path. Validate nonempty payloads if your endpoint requires a write. Non-object batch items receive an InvalidPayloadRow failure. Runtime exceptions may still interrupt processing; the batch helper is not a transactional job processor.

There is no batch-wide transaction or automatic idempotency key. For an all-or-nothing operation, implement a service that owns the transaction and fails it when any row is rejected. Retry only failed rows after a partial result; replaying successful creates can create duplicates without a unique key.

Delete

curl -X DELETE 'http://127.0.0.1:8080/api/project/delete?id=4' \
  -b cookies.txt -c cookies.txt \
  -H "X-Authorization: Bearer $API_TOKEN"

HTTP 200, response: true:

{
  "deleted": true,
  "data": {"id": 4, "label": "Playground", "status": "active", "budget": 500},
  "messages": []
}

Deletion delegates to the model. A model with Core's configured soft-delete behavior marks the row deleted; otherwise the model/database controls physical deletion. A subsequent normal find-first no longer sees a soft-deleted row. A missing or inaccessible target returns 404.

Restore

Restoration needs a model implementing SoftDeleteInterface, a restore controller grant, the relevant model grants, and a query that can see deleted rows. The normal deleted = 0 condition otherwise prevents lookup.

A focused controller override can remove only that condition for restoration:

public function getSoftDeleteColumn(): ?string
{
    return $this->dispatcher->getActionName() === 'restore'
        ? null
        : parent::getSoftDeleteColumn();
}

Keep ownership/tenant conditions in place. Add POST to your method policy:

curl http://127.0.0.1:8080/api/project/restore \
  -b cookies.txt -c cookies.txt \
  -H "X-Authorization: Bearer $API_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"id":4}'

Success is HTTP 200 with view.restored: true, view.data, and view.messages: []. Missing targets return 404; validation failures return 400/422 or an explicit model message status. Unsupported model interfaces are configuration errors, not client validation errors.

Reorder

For a model configured with Core's position behavior and PositionInterface, grant reorder, constrain its row scope, and send:

curl http://127.0.0.1:8080/api/task/reorder \
  -b cookies.txt -c cookies.txt \
  -H "X-Authorization: Bearer $API_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"id":12,"position":2}'

Success is HTTP 200 with view.reordered: true, exposed data, and messages. The tutorial's Project table has no position column; this is a separate resource capability. Define position grouping in the model so reordering one project's tasks cannot move another project's tasks. Test both the target and neighboring positions after the operation.

For nested child writes, continue with Relationships.