brbunny / brlayer
Camada de persistência com PDO e prepared statements: model Active Record com CRUD, consultas encadeadas e SQL próprio seguro.
Requires
- php: >=8.2
- ext-pdo: *
Requires (Dev)
- phpunit/phpunit: ^11.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-28 16:34:47 UTC
README
Camada de persistência para PHP 8.2+ com PDO e prepared statements. Você cria uma model por tabela (padrão Active Record) e cadastra, lê, edita e remove dados com poucas linhas.
Fork mantido da CoffeeCode DataLayer, de Robson V. Leite, atualizado para o PHP 8.x e com correções de segurança. Veja Migrando da CoffeeCode DataLayer.
Instalação
composer require brbunny/brlayer
Conexão
const BRLAYER_CONFIG = [ "driver" => "mysql", "host" => "localhost", "port" => "3306", "dbname" => "meu_banco", "username" => "root", "passwd" => "", "charset" => "utf8mb4", "options" => [ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_OBJ ] ];
charset(só MySQL) entra no DSN. Useutf8mb4para aceitar emojis.dsn(opcional) substitui o DSN montado, ex.:"dsn" => "sqlite:/caminho/banco.db".- O modo de erro do PDO é sempre
ERRMODE_EXCEPTION, independente dasoptions.
Se a conexão falhar, a exceção real (ex.: Access denied for user...) vai para o fail() da model,
ou é lançada com $throwOnFail.
Proteções da conexão:
- Um comando por chamada (MySQL):
"...; DROP TABLE x"é recusado pelo banco, mesmo que algum SQL seja montado concatenando valores. Para liberar, passePdo\Mysql::ATTR_MULTI_STATEMENTS => truenasoptions. - Conexão perdida ("server has gone away"): a operação que pegou a queda falha normalmente e a
próxima abre uma conexão nova. A operação não é repetida automaticamente, para não duplicar
INSERT/UPDATE. Para descartar a conexão manualmente (ex.: em workers):
Connect::forget().
Model
use BrLayer\Model; class User extends Model { public function __construct() { // tabela, campos obrigatórios, chave primária (padrão "id"), banco (padrão BRLAYER_CONFIG) parent::__construct("users", ["first_name", "last_name"]); } }
Datas de criação e alteração
Desligadas por padrão. Declare só as colunas que a tabela tem:
class User extends Model { protected ?string $createdAt = "created_at"; // preenchida ao criar protected ?string $updatedAt = "updated_at"; // preenchida ao criar e ao alterar } class Log extends Model { protected ?string $createdAt = "log_created_at"; // tabela só com data de criação }
Lançar exceção nas falhas
Por padrão, as falhas ficam em fail() e o método devolve false/null. Com $throwOnFail, a exceção
original (com o código SQL, ex.: 23000 para duplicidade) é lançada:
class User extends Model { protected bool $throwOnFail = true; }
Consultas
$model = new User(); $users = $model->find()->fetch(true); // todos $users = $model->find()->limit(2)->offset(2)->order("first_name ASC")->fetch(true); $users = $model->find()->in("id", [1, 2, 3])->fetch(true); $user = $model->find("email = :email", ["email" => $email])->fetch(); // um registro $user = $model->findById(2); // id inteiro ou texto (UUID) $total = $model->find("genre = :g", ["g" => "f"])->count(); foreach ($users ?? [] as $user) { echo $user->first_name; }
- Cada
find()começa uma consulta nova: filtros, ordem e limites da anterior são descartados. fetch(true)devolve um array de models ounullquando não encontra nada.count()considera o WHERE e oin(), e ignoragroup,order,limiteoffset.
Parâmetros
Passe os valores sempre por placeholder, como array:
$model->find("first_name = :name AND last_name = :last", ["name" => "Ana", "last" => "Silva"]);
O formato antigo em query string ("name=Ana&last=Silva") ainda funciona, mas evite: nele + vira
espaço e um valor com & consegue alterar outros parâmetros.
Nomes de coluna validados
Os nomes de coluna não podem ir por placeholder, então a lib valida tudo que entra no SQL como nome:
order(), group(), as colunas do find(), a coluna do in() e as colunas gravadas pelo save().
Aceita letras, números, _, tabela.coluna e `crase`. O resto lança InvalidArgumentException.
$model->find()->order("us.first_name DESC, us.id"); // ok $model->find()->order($_GET["sort"]); // seguro: algo fora do padrão é recusado $model->find()->orderRaw("RAND()"); // sem validação: nunca passe dados do usuário
O WHERE ($terms) é SQL livre: escreva os placeholders e nunca concatene valores nele.
Salvar e remover
$user = new User(); $user->first_name = "Ana"; $user->last_name = "Silva"; $user->save(); // cria (sem id) ou atualiza (com id existente) $user = (new User())->findById(2); $user->first_name = "Bruna"; $user->save(); $user->destroy(); if ($user->fail()) { echo $user->fail()->getMessage(); }
- Campos obrigatórios aceitam
0e"0". Só faltam quando não existem, sãonullou"". - Com chave definida pelo sistema (ex.: UUID), o
save()cria quando o registro não existe. true/falsesão gravados como1/0eDateTimecomoY-m-d H:i:s.- Valor que não pode ir para o banco (ex.: array vindo de
campo[]=xno formulário) é uma falha comum: vai para ofail()(DomainException), ou é lançada com$throwOnFail. fail()volta anulla cada operação.
Dados de formulário: fill()
Preenche a model só com os campos permitidos. O resto (ex.: um is_admin injetado no formulário) é ignorado:
$user = (new User())->fill($_POST, ["first_name", "last_name", "email"]); $user->save();
Sem a lista, o fill() usa a propriedade $fillable da model. Sem nenhuma das duas, lança exceção.
A chave primária nunca entra pelo $fillable; só se for informada na lista do próprio fill().
Atribuições diretas continuam livres: $user->is_admin = 1;.
Métodos da model
Métodos públicos sem argumentos criados na sua model podem ser lidos como propriedade:
class User extends Model { public function fullName(): string { return "{$this->first_name} {$this->last_name}"; } } echo $user->full_name; // Ana Silva
Os métodos da própria BrLayer (save, destroy, find...) nunca são chamados assim.
Transação
Model::transaction(function () { $user->save(); $address->save(); });
Confirma no fim, ou desfaz tudo se qualquer exceção for lançada (e repassa a exceção). Dentro de outra
transação, só roda dentro dela. Para que uma falha de save() desfaça a transação, use $throwOnFail = true.
Para outro banco: Model::transaction($action, OUTRO_BANCO).
SQL próprio
Para agregações, JOIN, UNION e comandos em lote, a model tem métodos protegidos:
class User extends Model { public function totalsByGenre(array $ids): array { [$in, $params] = static::inList($ids, "uid"); // ":uid0, :uid1" + valores return $this->rows( "SELECT genre, COUNT(*) AS total FROM users WHERE id IN ({$in}) GROUP BY genre", $params ); } }
| Método | Devolve |
|---|---|
rows($sql, $params) |
todas as linhas (arrays associativos) |
row($sql, $params) |
a primeira linha ou null |
execute($sql, $params) |
quantidade de linhas alteradas |
static::inList($values, $name) |
placeholders e valores de um IN. Lista vazia vira NULL (não encontra nada) |
static::now($modify) |
data e hora no fuso do PHP, ex.: now("-30 days") |
Consultas com JOIN: Query
Para relatórios e consultas com mais de uma tabela, monte a consulta por partes. O resultado vem em arrays associativos:
use BrLayer\Query; $query = Query::create() ->select("u.id, u.first_name, c.name AS company") ->from("users u") ->leftJoin("companies c", "c.id", "u.company_id") ->where("u.status = ?", "ativo") ->andWhere("u.first_name LIKE ? OR u.email LIKE ?", "%{$busca}%") ->andWhereIn("u.company_id", $empresas) ->order("u.first_name") ->limit(20) ->offset(40); $total = $query->count(); // ignora order, limit e offset (para paginação) $users = $query->fetchArray(); // todas as linhas, ou null $user = $query->fetchArray(false); // só a primeira
Valores vão sempre por placeholder, em where(), andWhere() e orWhere():
| Chamada | Valores |
|---|---|
where("id > ? AND id < ?", [1, 9]) |
um por ?, na ordem |
where("name LIKE ? OR email LIKE ?", "%ana%") |
um valor só, usado em todos os ? |
where("email = :email", ["email" => $email]) |
por nome |
A quantidade de valores precisa bater com a de ?; senão a Query lança InvalidArgumentException.
Um ? dentro de um texto ('Quem?') não conta.
Detalhes:
- Cada condição entra entre parênteses:
where("a OR b")->andWhere("c")vira(a OR b) AND (c). andWhereIn()com lista vazia não encontra nada. Comtrueno terceiro parâmetro (NOT IN), lista vazia não exclui nada. Nulos e repetidos saem;0fica.join("LEFT JOIN roles r ON r.user_id = u.id AND r.name = :role")para ON com mais de uma condição, com os valores emparams(["role" => "admin"]).query("SELECT ...", "id > :id", ["id" => 2])usa um SQL inteiro escrito por você.where(),order(),limit()ecount()continuam funcionando.group()eorder()são validados como na model. Para funções, usegroupRaw()eorderRaw().limit(null)eoffset(null)removem o limite.- Falhas ficam em
fail(). Para lançar a exceção:Query::create()->throwOnFail(). - Outro banco:
Query::create(OUTRO_BANCO).
select(),from(), os joins e o texto dowhere()vão para o SQL como foram escritos. Nunca coloque dados do usuário neles: valores vão pelos placeholders.
Depuração
$model->find("id > :id", ["id" => 1])->order("id"); $model->getStatement(); // SELECT * FROM users WHERE id > :id ORDER BY id $model->getParams(); // ["id" => 1]
var_dump/print_r de uma model mostram passwd como ********.
Migrando da CoffeeCode DataLayer
- A classe base agora é
BrLayer\Model: troqueuse CoffeeCode\DataLayer\DataLayer;poruse BrLayer\Model;eextends DataLayerporextends Model. OConnectvirouBrLayer\Connect. A constanteDATA_LAYER_CONFIGcontinua funcionando; o nome novo éBRLAYER_CONFIG. - Timestamps agora são desligados por padrão. Nas models que usam
created_at/updated_at, declare$createdAte$updatedAt. O 4º parâmetro do construtor (bool $timestamps) saiu: o banco passou a ser o 4º. updated_atagora também é preenchido na criação.Connect::getInstance()lança a exceção quando não conecta (antes devolvianull).- Ler
$model->save,$model->destroyetc. como propriedade não executa mais o método. - O estado interno (
data,params,order...) é privado. Usedata(),getParams(),getStatement(). Model que sobrescrevecreate(),update(),rows()ouexecute()precisa ajustar a assinatura (os parâmetros agora aceitamarray|string|null). RAND()e outras funções noorder(): useorderRaw().in()com lista vazia não encontra nada (antes dava erro de SQL).
Testes
Os testes usam um MySQL de verdade (banco brlayer_test). Ajuste a conexão pelas variáveis
DB_HOST, DB_PORT, DB_NAME, DB_USER e DB_PASS (padrões no phpunit.xml):
mysql -uroot -p -e "CREATE DATABASE brlayer_test CHARACTER SET utf8mb4" DB_PASS=senha composer test
Créditos
- Robson V. Leite: autor da CoffeeCode DataLayer original
- Kevin Siqueira: BrLayer
Licença
MIT. Veja o LICENSE.