dr2gsistemas / xpress-orm
ORM ligero para PHP 8.4 con atributos, relaciones y auto-migrate para MariaDB
Requires
- php: ^8.4
- psr/log: ^3.0
Requires (Dev)
- pestphp/pest: ^3.8
- phpunit/phpunit: ^11.0
This package is auto-updated.
Last update: 2026-04-04 03:42:15 UTC
README
Xpress ORM es un mapper objeto-relacional ligero y rápido para PHP 8.4, diseñado con una sintaxis limpia usando atributos. Soporta MariaDB/MySQL con auto-migrate, relaciones completas y query builder integrado.
Características
- Atributos PHP 8.4 - Definiciones de entidades declarativas y limpìas
- Relaciones completas - One-to-One, One-to-Many, Many-to-One, Many-to-Many
- Auto-migrate - Sincronización automática de schema de base de datos
- Query Builder - Constructor de queries encadenable con escape automático (OWASP)
- Soft Deletes - Soporte nativo para eliminación suave
- Timestamps automáticos - created_at y updated_at automáticos
- Repositorios - Patrón Repository con métodos personalizados
- Transacciones - Soporte completo para transacciones
- Hydrator - Conversión bidireccional entre entidades y arrays
- Seguridad - Prepared statements obligatorios contra SQL Injection
Requisitos
- PHP 8.4 o superior
- Extensión PDO instalada
- MariaDB 10.5+ o MySQL 8.0+ (compatible con PostgreSQL con driver pdo_pgsql)
Instalación
composer require dr2gsistemas/xpress-orm
Configuración Rápida
1. Conexión a la Base de Datos
use Xpress\Orm\Connection\XConnection; $connection = new XConnection([ 'driver' => 'pdo_mysql', 'host' => 'localhost', 'database' => 'mi_app', 'username' => 'root', 'password' => 'secret', 'charset' => 'utf8mb4' ]);
2. Entity Manager
use Xpress\Orm\Entity\XEntityManager; $em = new XEntityManager($connection);
3. Auto-migrate (crear/actualizar tablas)
use Xpress\Orm\Schema\XAutoMigrate; $migrate = new XAutoMigrate($connection); $migrate->updateSchema([User::class, Post::class, Category::class]);
Definición de Entidades
Entidad Básica con Atributos
<?php namespace App\Models; use Xpress\Orm\Attributes\Entity\{XEntity, XColumn, XId, XIndex}; use Xpress\Orm\Traits\TimestampableTrait; use Xpress\Orm\Traits\SoftDeleteTrait; #[XEntity(table: 'users')] #[XIndex(columns: ['email'], unique: true)] class User { use TimestampableTrait; use SoftDeleteTrait; #[XId] #[XColumn(type: 'int', increment: true)] private ?int $id = null; #[XColumn(type: 'varchar', length: 100)] private string $name; #[XColumn(type: 'varchar', length: 255, unique: true)] private string $email; #[XColumn(type: 'varchar', length: 255)] private ?string $password = null; #[XColumn(type: 'enum', enum: ['admin', 'user', 'guest'], default: 'user')] private string $role = 'user'; // Getters y setters... }
Tipos de Columnas Soportados
| Tipo | Descripción |
|---|---|
int |
Entero |
bigint |
Entero grande |
varchar(length) |
Cadena de texto |
text |
Texto largo |
boolean |
Booleano (TINYINT) |
decimal(precision, scale) |
Número decimal |
float |
Flotante |
date |
Fecha |
datetime |
Fecha y hora |
timestamp |
Timestamp |
enum(['a', 'b', 'c']) |
Enum |
json |
JSON |
blob |
Datos binarios |
Opciones de Columnas
#[XColumn(
type: 'varchar',
length: 255,
name: 'custom_column_name', // Nombre de columna (opcional)
nullable: true, // Permitir nulos
default: 'value', // Valor por defecto
unique: true, // Índice único
increment: true, // Auto-incremento
comment: 'Descripción' // Comentario
)]
Relaciones entre Entidades
One-to-Many (Un usuario tiene muchos posts)
#[XEntity(table: 'users')] class User { #[XRelation(oneToMany: Post::class, mappedBy: 'author')] private ?Collection $posts = null; public function getPosts(): Collection { return $this->posts ?? new Collection(); } } #[XEntity(table: 'posts')] class Post { #[XRelation(manyToOne: User::class, joinColumn: 'user_id')] private ?User $author = null; public function getAuthor(): ?User { return $this->author; } }
Many-to-Many (Posts tienen muchos Tags)
class Post { #[XRelation(manyToMany: Tag::class, joinTable: 'post_tags')] private ?Collection $tags = null; public function addTag(Tag $tag): void { $this->tags[] = $tag; } }
Traits Disponibles
TimestampableTrait
Añade created_at y updated_at automáticos:
use Xpress\Orm\Traits\TimestampableTrait; class User { use TimestampableTrait; // Los timestamps se actualizan automáticamente al guardar }
SoftDeleteTrait
Añade eliminación suave con deleted_at:
use Xpress\Orm\Traits\SoftDeleteTrait; class User { use SoftDeleteTrait; // $user->softDelete() marca deleted_at // $user->restore() limpia deleted_at // $em->delete($user) usa soft delete por defecto }
CRUD con Entity Manager
Crear
$user = new User(); $user->setName('Juan Pérez'); $user->setEmail('juan@ejemplo.com'); $user->hashPassword('secret123'); $em->save($user); echo "ID: " . $user->getId(); // ID: 1
Leer
// Por ID $user = $em->find(User::class, 1); // Por criterios $user = $em->findOneBy(User::class, ['email' => 'juan@ejemplo.com']); // Múltiples resultados $admins = $em->findBy(User::class, ['role' => 'admin']); // Todos los registros $users = $em->findAll(User::class); // Contar $total = $em->count(User::class, ['status' => 'active']); // Verificar existencia $exists = $em->exists(User::class, 1);
Actualizar
$user = $em->find(User::class, 1); $user->setName('Juan Actualizado'); $em->save($user); // Actualiza automáticamente
Eliminar
// Soft delete (por defecto) $em->delete($user); // Hard delete (eliminación permanente) $em->delete($user, hard: true); // Eliminar por ID $em->deleteById(User::class, 1); // Eliminar todos $em->deleteAll(User::class, ['role' => 'inactive']);
Repositorios
Definir Repositorio
<?php namespace App\Repositories; use App\Models\User; use Xpress\Orm\Attributes\Repository\XRepository; use Xpress\Orm\Repository\XBaseRepository; #[XRepository(entity: User::class)] class UserRepository extends XBaseRepository { public function findByEmail(string $email): ?User { return $this->findOneByColumn('email', $email); } public function findActiveUsers(): array { return $this->findBy(['status' => 'active']); } public function search(string $query): array { return $this->search($query, ['name', 'email']); } public function paginated(int $page = 1, int $perPage = 20): array { return $this->paginate($page, $perPage); } }
Usar Repositorio
$repo = $em->getRepository(UserRepository::class); // Métodos heredados $user = $repo->find(1); $admins = $repo->findBy(['role' => 'admin']); $repo->save($user); // Métodos personalizados $user = $repo->findByEmail('juan@ejemplo.com'); $active = $repo->findActiveUsers(); $search = $repo->search('Juan'); // Paginación $pagination = $repo->paginated(2, 10); // ['items' => [...], 'total' => 50, 'page' => 2, 'per_page' => 10, 'pages' => 5]
Query Builder
Uso Básico
$users = $em->createQuery(User::class) ->select('*') ->from('users') ->where('status', 'active') ->orderBy('created_at', 'DESC') ->limit(10) ->getResult();
Condiciones
// WHERE simple ->where('age', 25) ->where('status', 'active') // WHERE IN ->whereIn('id', [1, 2, 3]) // WHERE NULL ->whereNull('deleted_at') ->whereNotNull('published_at') // WHERE BETWEEN ->whereBetween('age', 18, 65) // WHERE LIKE ->whereLike('name', 'Juan') // %Juan% ->whereLike('name', 'Juan', 'start') // Juan% ->whereLike('name', 'Juan', 'end') // %Juan // OR ->where('status', 'active') ->orWhere('role', 'admin') // RAW ->whereRaw('YEAR(created_at) = ?', [2024])
Joins
->select(['u.*', 'COUNT(p.id) as post_count']) ->from('users', 'u') ->leftJoin('posts', 'u.id = p.user_id', 'p') ->groupBy('u.id') ->having('COUNT(p.id) > ?', 5)
Paginación
// Página 2, 10 items por página ->page(2, 10) // LIMIT 10 OFFSET 10
Obtener Resultados
->getResult() // Array de resultados ->getOne() // Un resultado o null ->getColumn() // Primera columna ->getValue() // Alias de getColumn ->count() // Conteo ->exists() // Boolean
Transacciones
try { $em->beginTransaction(); $user1 = new User(); $user1->setName('Usuario 1'); $user1->setEmail('u1@ejemplo.com'); $em->save($user1); $user2 = new User(); $user2->setName('Usuario 2'); $user2->setEmail('u2@ejemplo.com'); $em->save($user2); $em->commit(); } catch (\Exception $e) { $em->rollback(); }
Auto-migrate
Sincronizar Schema
$migrate = new XAutoMigrate($connection); // Verbose mode $migrate->setVerbose(true); // Actualizar todas las tablas $logs = $migrate->updateSchema([User::class, Post::class, Category::class]); // Crear schema (solo crea, no modifica) // $migrate->createSchema([...]); // Eliminar schema // $migrate->dropSchema([...]);
Lo que hace Auto-migrate
- Crea tablas que no existen
- Añade columnas nuevas
- Crea índices definidos
- Crea tablas de join para Many-to-Many
- Añade foreign keys
Ejemplo Completo
<?php require_once 'vendor/autoload.php'; use App\Models\{User, Post, Category}; use App\Repositories\{UserRepository, PostRepository}; use Xpress\Orm\Connection\XConnection; use Xpress\Orm\Entity\XEntityManager; use Xpress\Orm\Schema\XAutoMigrate; $connection = new XConnection([ 'driver' => 'pdo_mysql', 'host' => 'localhost', 'database' => 'mi_blog', 'username' => 'root', 'password' => '' ]); // Sincronizar schema $migrate = new XAutoMigrate($connection); $migrate->updateSchema([User::class, Post::class, Category::class]); // Entity Manager $em = new XEntityManager($connection); // Crear usuario $user = new User(); $user->setName('Juan Pérez'); $user->setEmail('juan@ejemplo.com'); $user->setRole('admin'); $user->hashPassword(password_hash('secret123', PASSWORD_DEFAULT)); $em->save($user); // Crear categoría $category = new Category(); $category->setName('Tecnología'); $category->setDescription('Artículos de tecnología'); $em->save($category); // Crear post $post = new Post(); $post->setTitle('Mi Primer Post'); $post->setContent('<p>Contenido del post...</p>'); $post->setAuthor($user); $post->setCategory($category); $post->publish(); $em->save($post); // Consultar con repositorio $repo = $em->getRepository(PostRepository::class); $published = $repo->findPublished(); // Paginación $pagination = $repo->paginate(1, 10); // Búsqueda $results = $repo->searchPosts('primer'); // Relaciones con eager loading $post = $repo->findWithRelations(1); echo $post->getAuthor()->getName(); echo $post->getCategory()->getName();
Seguridad OWASP
Prepared Statements
Todos los queries usan prepared statements automáticamente:
// ✅ Seguro $qb->where('email', $email); // Prepared statement // ❌ Peligroso (no recomendado) // Usar whereRaw solo con parámetros $qb->whereRaw('email = ?', [$email]);
Prevención de SQL Injection
- NUNCA concatenar variables directamente en SQL
- Usar siempre
->setParameter()o el sistema de condiciones - Sanitizar inputs del usuario antes de usarlos
Mejores Prácticas
1. Siempre usar tipos estrictos
declare(strict_types=1);
2. No exponer passwords en arrays
public function toArray(): array { $data = parent::toArray(); unset($data['password']); // Nunca incluir passwords return $data; }
3. Usar transacciones para operaciones múltiples
$em->beginTransaction(); try { // Operaciones... $em->commit(); } catch (\Exception $e) { $em->rollback(); }
4. Preferir repositorios sobre queries directas
// ✅ Mejor $repo = $em->getRepository(UserRepository::class); $user = $repo->findByEmail($email); // ✅ Aceptable para queries complejas $qb = $em->createQuery(User::class);
API Reference
XEntityManager
$em->find($class, $id); // Buscar por ID $em->findOneBy($class, $criteria); // Uno por criterios $em->findBy($class, $criteria, $options, $limit, $offset); $em->findAll($class); // Todos $em->save($entity); // Crear/actualizar $em->delete($entity, $hard = false); // Eliminar $em->count($class, $criteria); // Contar $em->exists($class, $id); // Verificar existencia $em->createQuery($class); // Query builder $em->getRepository($repoClass); // Repositorio $em->extract($entity); // Entidad a array $em->beginTransaction(); $em->commit(); $em->rollback();
XQueryBuilder
$qb->select($columns); $qb->from($table, $alias); $qb->where($column, $value); $qb->whereIn($column, $values); $qb->whereNull($column); $qb->whereNotNull($column); $qb->whereBetween($column, $val1, $val2); $qb->whereLike($column, $value); $qb->whereRaw($sql, $params); $qb->orWhere($column, $value); $qb->join($table, $condition, $alias, $type); $qb->leftJoin($table, $condition, $alias); $qb->innerJoin($table, $condition, $alias); $qb->groupBy($columns); $qb->having($condition, $value); $qb->orderBy($column, $direction); $qb->limit($limit); $qb->offset($offset); $qb->page($page, $perPage); $qb->setParameter($key, $value); $qb->getParameters(); $qb->getSQL(); $qb->getResult(); $qb->getOne(); $qb->count(); $qb->exists(); $qb->reset();
XBaseRepository
$repo->find($id); $repo->findOne($criteria); $repo->findBy($criteria, $options, $limit, $offset); $repo->findAll(); $repo->save($entity); $repo->saveAll($entities); $repo->delete($entity, $hard = false); $repo->count($criteria); $repo->exists($id); $repo->refresh($entity); $repo->detach($entity); $repo->createQueryBuilder(); $repo->findWith($relations); $repo->paginate($page, $perPage, $criteria); $repo->first($criteria); $repo->last($criteria); $repo->search($query, $fields); $repo->existsBy($criteria);
Patrón Result
Xpress ORM incluye un patrón Result para manejar operaciones de base de datos de forma funcional, evitando excepciones y permitiendo encadenamiento.
Métodos Result en Repositorio
// findOrFail - Retorna XResult en vez de lanzar excepción $result = $repo->findOrFail($id); if ($result->isSuccess()) { $user = $result->getValue(); } else { $error = $result->getError(); echo $error->getMessage(); // "User not found" } // findOneOrFail - Busca uno o retorna error $result = $repo->findOneOrFail(['email' => 'test@example.com']); // saveOrFail - Guarda y retorna 201 si es nuevo, 200 si es actualización $result = $repo->saveOrFail($user); // deleteOrFail - Elimina sin excepción $result = $repo->deleteOrFail($user); // deleteByIdOrFail - Encuentra y elimina $result = $repo->deleteByIdOrFail($id); // existsOrFail - Verifica existencia $result = $repo->existsOrFail($id); // paginateResult - Paginación con Result $result = $repo->paginateResult(1, 20, ['status' => 'active']); // searchResult - Búsqueda con Result $result = $repo->searchResult('john', ['name', 'email']);
Métodos Result en EntityManager
$em = new XEntityManager($connection); // findOrFail $result = $em->findOrFail(User::class, $id); // saveOrFail $result = $em->saveOrFail($user); // deleteOrFail $result = $em->deleteOrFail($user);
Trait XResultRepository
Para uso en repositorios personalizados:
<?php use Xpress\Orm\Attributes\Repository\XRepository; use Xpress\Orm\Entity\XEntityManager; use Xpress\Orm\Result\XResultRepository; use Xpress\Orm\Result\XResult; #[XRepository(entity: User::class)] class UserRepository extends XBaseRepository { use XResultRepository; public function findByEmail(string $email): XResult { return $this->findOneResult(['email' => $email]); } public function activate(int $id): XResult { return $this->findResult($id) ->andThen(fn($user) => $this->try(fn() => $this->saveOrFail($user))); } }
XResult - API
use Xpress\Orm\Result\XResult; // Verificación $result->isSuccess(); // true/false $result->isFailure(); // true/false $result->isNotFound(); // true si es 404 $result->isConflict(); // true si es 409 $result->isServerError(); // true si es 5xx // Extracción de valores $result->getValue(); // valor o null $result->getValueOr($default); // valor o default $result->unwrap(); // lanza excepción si error $result->unwrapOrNull(); // null si error // Manejo de errores $result->getError(); // XError o null $result->getErrorMessage(); // string del error $result->getErrorCode(); // código HTTP $result->getErrorData(); // datos adicionales // Encadenamiento $result->map(fn($v) => transform($v)); $result->andThen(fn($v) => otraOperacion($v)); $result->mapError(fn($e) => XError::...); $result->orElse(fn($e) => $fallback);
XError - API
use Xpress\Orm\Result\XError; XError::notFound('Usuario no encontrado'); XError::conflict('Email ya existe'); XError::database('Error de conexión'); XError::validation(['email' => 'inválido']);
Ejemplo Completo
<?php use Xpress\Orm\Attributes\Repository\XRepository; use Xpress\Orm\Result\XResultRepository; use Xpress\Orm\Result\XResult; #[XRepository(entity: User::class)] class UserRepository extends XBaseRepository { use XResultRepository; public function findActiveUser(int $id): XResult { return $this->findResult($id) ->andThen(fn($user) => $user->isActive() ? XResult::ok($user) : XResult::fail('Usuario inactivo', 403)); } public function createUser(array $data): XResult { return $this->try(function() use ($data) { $existing = $this->findOne(['email' => $data['email']]); if ($existing !== null) { return XResult::fail('Email ya registrado', 409); } $user = new User($data); return $this->saveResult($user); }); } } // Uso en controlador $userRepo = $em->getRepository(UserRepository::class); $result = $userRepo->createUser(['name' => 'Juan', 'email' => 'juan@test.com']); $result ->map(fn($user) => $user->toArray()) ->andThen(fn($data) => XResult::ok($this->sendEmail($data))) ->mapError(fn($e) => $this->logError($e));
Compatibilidad hacia atrás
Todos los métodos tradicionales siguen disponibles. Los métodos Result son adicionales:
// Tradicional - lanza excepción si no existe $user = $repo->find($id); // null si no existe // Result - sin excepción $result = $repo->findOrFail($id);
Testing
# Ejecutar tests con Pest (recomendado) ./vendor/bin/pest # O con Composer composer test
Licencia
MIT License - ver archivo LICENSE para más detalles.
Contributing
- Fork el repositorio
- Crea una rama para tu feature (
git checkout -b feature/nueva-funcion) - Commit tus cambios (
git commit -am 'Agregar nueva función') - Push a la rama (
git push origin feature/nueva-funcion) - Crea un Pull Request