Result and Errors in Bitrix
Baseline: main 23.0+.
Philosophy
- User Errors →
Result+Error. Validation, business rules, "entity not found," "insufficient permissions." - Program Errors → Exceptions. Connection failure, missing mandatory module, broken configuration.
- One method — one clear contract: either it always returns
Result(and doesn't throw "normal" exceptions), or it guaranteed returns a value and throws\Throwablein exceptional cases.
Bitrix\Main\Result
Base object:
$result = new \Bitrix\Main\Result();
if ($titleEmpty)
{
$result->addError(new \Bitrix\Main\Error('Title required', 'POST_TITLE_EMPTY'));
}
if (!$result->isSuccess()) { return $result; }
$result->setData(['id' => $id]);
return $result;
API:
isSuccess(): boolgetErrors(): Error[]getErrorMessages(): string[]getErrorCollection(): ErrorCollectionaddError(Error $e): selfaddErrors(array $errors): selfsetData(array $data): selfgetData(): array
Bitrix\Main\Error
new \Bitrix\Main\Error(
message: 'Title required',
code: 'POST_TITLE_EMPTY',
customData: ['field' => 'title'],
);
The error code should be stable and machine-readable — clients should rely on it, not the text.
ErrorCollection
$errors = new \Bitrix\Main\ErrorCollection();
$errors->setError(new \Bitrix\Main\Error('...', 'CODE_A'));
$errors->add([new \Bitrix\Main\Error('...', 'CODE_B')]);
foreach ($errors as $error) { /* ... */ }
$errors->getErrorByCode('CODE_A');
Controller already contains protected ErrorCollection $errorCollection — it is used by $this->addError(...) and $this->addErrors(...).
Typed Result for Service
Instead of setData(['post' => $post]), it's easier to inherit and provide normal getters:
<?php declare(strict_types=1);
namespace Vendor\Blog\Application\Result;
use Bitrix\Main\Result;
use Vendor\Blog\Domain\Model\Post;
final class CreatePostResult extends Result
{
private ?Post $post = null;
public function setPost(Post $post): self
{
$this->post = $post;
$this->setData(['post' => $post]);
return $this;
}
public function getPost(): ?Post
{
return $this->post;
}
}
Usage:
public function create(CreatePostRequest $request): CreatePostResult
{
$result = new CreatePostResult();
$validation = $this->validator->validate($request);
if (!$validation->isSuccess())
{
foreach ($validation->getErrors() as $error)
{
$result->addError(new \Bitrix\Main\Error($error->getMessage(), $error->getCode()));
}
return $result;
}
$post = $this->repository->save(Post::fromRequest($request));
return $result->setPost($post);
}
Specialized ORM Results
AddResult—getId().UpdateResult—getAffectedRowsCount(),getPrimary().DeleteResult—getAffectedRowsCount().
They inherit from Result and work with the same API. Example:
$add = PostTable::add(['TITLE' => 'Hi', 'AUTHOR_ID' => 1]);
if (!$add->isSuccess())
{
$this->logger->warning('Add post failed', ['errors' => $add->getErrorMessages()]);
return $add; // proxy upwards
}
$id = $add->getId();
EventResult
Handlers of \Bitrix\Main\Event return \Bitrix\Main\EventResult with type EventResult::SUCCESS, EventResult::ERROR, or EventResult::UNDEFINED (not a drop-in for service Result). Details: skill bitrix-events.
Attribute / ValidationService checks yield \Bitrix\Main\Validation\ValidationResult (also isSuccess() + errors) — skill bitrix-validation. Merge those errors into a service Result when crossing the module boundary.
Returning Errors in Controller
public function createAction(CreatePostRequest $request): array
{
// Wire CreatePostRequest via ValidationParameter in getAutoWiredParameters()
// (AutoWire Parameter, not a PHP attribute) — see bitrix-validation.
$result = $this->postService->create($request);
if (!$result->isSuccess())
{
$this->addErrors($result->getErrors());
return [];
}
return ['id' => $result->getPost()->getId()];
}
The front-end will receive the following structure:
{
"status": "error",
"errors": [
{ "message": "Title required", "code": "POST_TITLE_EMPTY", "customData": { "field": "title" } }
]
}
Code Conventions
- Module prefix + entity + reason:
BLOG_POST_NOT_FOUND,BLOG_POST_TITLE_EMPTY. - Do not use
"1","ERROR",""— it doesn't allow the client to react. - Translated texts — via
Loc::getMessage('BLOG_ERROR_POST_TITLE_EMPTY'), code — via constant.
When to use Exceptions?
- Invalid system state: "module not installed," "database not responding," "service misconfigured."
- Developer contract violation:
InvalidArgumentException,LogicException,TypeError. - Inside domain operations where "error = bug." For example,
Money::divide()with zero.
Exceptions are caught at the module/controller boundary and turned into Result + log:
try
{
return $this->service->operation($dto);
}
catch (\Bitrix\Main\SystemException $e)
{
$this->logger->error('Operation failed', ['exception' => $e]);
$result = new \Bitrix\Main\Result();
$result->addError(new \Bitrix\Main\Error('Internal error', 'INTERNAL_ERROR'));
return $result;
}
Antipatterns
- Returning
bool/null/-1from a service instead ofResult. - Throwing
\Exception('Post not found')for a routine situation — that's not an exception. - Swallowing
catch (\Throwable $e) {}without logging. - Error in the form of an array
['error' => 'msg']— unify usingResult.
Controller JSON error format: {"status":"error","errors":[{"message":"...","code":"..."}]}. See skill bitrix-controllers.