Spot
Spot is "Simple PHP ORM" that serves as a lightweight and simple Object-Relational Mapping (ORM) library for PHP applications. Designed as an alternative to the heavyweight nature of Doctrine ORM, it supports both Active Record and DataMapper patterns while providing an intuitive API with low learning costs. It realizes modern ORM features such as migration functionality, relationship management, and query builders in a lightweight package, making it an optimal choice for small to medium-scale PHP projects.
GitHub Overview
spotorm/spot2
Spot v2.x DataMapper built on top of Doctrine's Database Abstraction Layer
Topics
Star History
Library
Spot
Overview
Spot is "Simple PHP ORM" that serves as a lightweight and simple Object-Relational Mapping (ORM) library for PHP applications. Designed as an alternative to the heavyweight nature of Doctrine ORM, it supports both Active Record and DataMapper patterns while providing an intuitive API with low learning costs. It realizes modern ORM features such as migration functionality, relationship management, and query builders in a lightweight package, making it an optimal choice for small to medium-scale PHP projects.
Details
Spot 2025 edition supports PHP 8.0 and later, providing safer and more expressive database programming by leveraging modern PHP features (type declarations, attributes, enums, etc.). It significantly improves developer productivity through simple Entity class definitions, flexible query builders, automatic schema migration, and relationship management. While not as feature-rich as Laravel's Eloquent or as complex as Doctrine, it provides a "just right" level of functionality set, enabling quick project startup with Composer management and minimal configuration.
Key Features
- Lightweight Design: Minimal dependencies and footprint
- Intuitive API: Simple interface with low learning costs
- Migration: Automatic schema change management functionality
- Relationships: Support for one-to-one, one-to-many, and many-to-many relationships
- Query Builder: Flexible and readable query construction
- Data Validation: Entity-level validation functionality
Pros and Cons
Pros
- Lightweight with lower learning costs compared to Doctrine or Eloquent
- Improved development efficiency through simple and intuitive API
- Simplified schema management through automatic migration functionality
- Flexibility through selective use of Active Record and DataMapper patterns
- Quick project startup possible with minimal configuration
- Natural integration with Composer ecosystem
Cons
- Not as feature-rich as Laravel's Eloquent
- Limited track record and ecosystem for large-scale projects
- Complex queries and advanced ORM features are inferior to other libraries
- Small community size with limited information and plugins
- Enterprise-level features (caching, etc.) require separate implementation
- Limited performance optimization options
Reference Pages
Code Examples
Setup
// composer.json
{
"require": {
"vlucas/spot2": "^2.3",
"php": ">=8.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
# Installation
composer install
<?php
// config/database.php
use Spot\Locator;
use Spot\Config;
// Database configuration
$cfg = new Config();
// SQLite configuration example
$cfg->addConnection('default', [
'dbname' => __DIR__ . '/../database/app.sqlite',
'driver' => 'pdo_sqlite'
]);
// MySQL configuration example
$cfg->addConnection('mysql', [
'driver' => 'pdo_mysql',
'host' => 'localhost',
'dbname' => 'spot_demo',
'user' => 'root',
'password' => '',
'charset' => 'utf8mb4'
]);
// PostgreSQL configuration example
$cfg->addConnection('postgresql', [
'driver' => 'pdo_pgsql',
'host' => 'localhost',
'dbname' => 'spot_demo',
'user' => 'postgres',
'password' => '',
'charset' => 'utf8'
]);
$spot = new Locator($cfg);
return $spot;
Basic Usage
<?php
namespace App\Entity;
use Spot\EntityInterface;
use Spot\MapperInterface;
use Spot\Entity;
// User entity
class User extends Entity
{
protected static $table = 'users';
public static function fields()
{
return [
'id' => ['type' => 'integer', 'primary' => true, 'autoincrement' => true],
'name' => ['type' => 'string', 'required' => true],
'email' => ['type' => 'string', 'required' => true, 'unique' => true],
'age' => ['type' => 'integer', 'default' => 0],
'active' => ['type' => 'boolean', 'default' => true],
'created_at' => ['type' => 'datetime', 'value' => new \DateTime()],
'updated_at' => ['type' => 'datetime', 'value' => new \DateTime()]
];
}
public static function relations(MapperInterface $mapper, EntityInterface $entity)
{
return [
'posts' => $mapper->hasMany($entity, 'App\Entity\Post', 'user_id'),
'profile' => $mapper->hasOne($entity, 'App\Entity\UserProfile', 'user_id'),
];
}
// Custom method
public function getFullInfo()
{
return sprintf('%s (%s) - Age: %d', $this->name, $this->email, $this->age);
}
// Validation
public static function validate($data)
{
$errors = [];
if (empty($data['name'])) {
$errors['name'] = 'Name is required';
}
if (empty($data['email']) || !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
$errors['email'] = 'Valid email is required';
}
if (isset($data['age']) && ($data['age'] < 0 || $data['age'] > 150)) {
$errors['age'] = 'Age must be between 0 and 150';
}
return $errors;
}
}
// Post entity
class Post extends Entity
{
protected static $table = 'posts';
public static function fields()
{
return [
'id' => ['type' => 'integer', 'primary' => true, 'autoincrement' => true],
'title' => ['type' => 'string', 'required' => true],
'content' => ['type' => 'text'],
'user_id' => ['type' => 'integer', 'required' => true],
'published' => ['type' => 'boolean', 'default' => false],
'published_at' => ['type' => 'datetime'],
'created_at' => ['type' => 'datetime', 'value' => new \DateTime()],
'updated_at' => ['type' => 'datetime', 'value' => new \DateTime()]
];
}
public static function relations(MapperInterface $mapper, EntityInterface $entity)
{
return [
'user' => $mapper->belongsTo($entity, 'App\Entity\User', 'user_id'),
'comments' => $mapper->hasMany($entity, 'App\Entity\Comment', 'post_id'),
];
}
// Scope method
public static function published($mapper)
{
return $mapper->where(['published' => true]);
}
public function publish()
{
$this->published = true;
$this->published_at = new \DateTime();
$this->updated_at = new \DateTime();
}
}
// User profile entity
class UserProfile extends Entity
{
protected static $table = 'user_profiles';
public static function fields()
{
return [
'id' => ['type' => 'integer', 'primary' => true, 'autoincrement' => true],
'user_id' => ['type' => 'integer', 'required' => true, 'unique' => true],
'bio' => ['type' => 'text'],
'website' => ['type' => 'string'],
'location' => ['type' => 'string'],
'birth_date' => ['type' => 'date'],
'avatar_url' => ['type' => 'string'],
'created_at' => ['type' => 'datetime', 'value' => new \DateTime()],
'updated_at' => ['type' => 'datetime', 'value' => new \DateTime()]
];
}
public static function relations(MapperInterface $mapper, EntityInterface $entity)
{
return [
'user' => $mapper->belongsTo($entity, 'App\Entity\User', 'user_id'),
];
}
}
// Basic operations service
class UserService
{
private $spot;
public function __construct($spot)
{
$this->spot = $spot;
}
// Schema creation
public function createSchema()
{
$userMapper = $this->spot->mapper('App\Entity\User');
$postMapper = $this->spot->mapper('App\Entity\Post');
$profileMapper = $this->spot->mapper('App\Entity\UserProfile');
// Table creation
$userMapper->migrate();
$postMapper->migrate();
$profileMapper->migrate();
echo "Database schema created successfully\n";
}
// User creation
public function createUser($name, $email, $age = null)
{
$data = compact('name', 'email', 'age');
// Validation
$errors = User::validate($data);
if (!empty($errors)) {
throw new \InvalidArgumentException('Validation failed: ' . implode(', ', $errors));
}
$userMapper = $this->spot->mapper('App\Entity\User');
// Duplicate check
$existingUser = $userMapper->first(['email' => $email]);
if ($existingUser) {
throw new \InvalidArgumentException('Email already exists');
}
$user = $userMapper->create($data);
if ($user) {
echo "User created: {$user->getFullInfo()}\n";
return $user;
}
throw new \RuntimeException('Failed to create user');
}
// Get all users
public function getAllUsers()
{
$userMapper = $this->spot->mapper('App\Entity\User');
return $userMapper->all()->order(['name' => 'ASC']);
}
// Get user by ID
public function getUserById($id)
{
$userMapper = $this->spot->mapper('App\Entity\User');
return $userMapper->get($id);
}
// Update user
public function updateUser($id, $data)
{
$errors = User::validate($data);
if (!empty($errors)) {
throw new \InvalidArgumentException('Validation failed: ' . implode(', ', $errors));
}
$userMapper = $this->spot->mapper('App\Entity\User');
$user = $userMapper->get($id);
if (!$user) {
throw new \InvalidArgumentException('User not found');
}
// Email duplicate check (excluding self)
if (isset($data['email']) && $data['email'] !== $user->email) {
$existingUser = $userMapper->first(['email' => $data['email']]);
if ($existingUser) {
throw new \InvalidArgumentException('Email already exists');
}
}
$data['updated_at'] = new \DateTime();
$result = $userMapper->update($user, $data);
if ($result) {
echo "User updated successfully\n";
return $userMapper->get($id); // Return updated data
}
throw new \RuntimeException('Failed to update user');
}
// Delete user
public function deleteUser($id)
{
$userMapper = $this->spot->mapper('App\Entity\User');
$user = $userMapper->get($id);
if (!$user) {
throw new \InvalidArgumentException('User not found');
}
$result = $userMapper->delete($user);
if ($result) {
echo "User deleted successfully\n";
return true;
}
throw new \RuntimeException('Failed to delete user');
}
// Search functionality
public function searchUsers($query)
{
$userMapper = $this->spot->mapper('App\Entity\User');
return $userMapper->where(['name :like' => "%{$query}%"])
->orWhere(['email :like' => "%{$query}%"])
->order(['name' => 'ASC']);
}
// Filter by age range
public function getUsersByAgeRange($minAge, $maxAge)
{
$userMapper = $this->spot->mapper('App\Entity\User');
return $userMapper->where(['age :gte' => $minAge])
->where(['age :lte' => $maxAge])
->order(['age' => 'ASC', 'name' => 'ASC']);
}
}
// Usage example
function demonstrateBasicOperations()
{
$spot = require __DIR__ . '/config/database.php';
$userService = new UserService($spot);
try {
// Schema creation
$userService->createSchema();
// User creation
$user1 = $userService->createUser('Alice Smith', '[email protected]', 25);
$user2 = $userService->createUser('Bob Johnson', '[email protected]', 30);
$user3 = $userService->createUser('Charlie Brown', '[email protected]', 35);
// Get all users
echo "\n=== All Users ===\n";
$users = $userService->getAllUsers();
foreach ($users as $user) {
echo $user->getFullInfo() . "\n";
}
// Get specific user
$user = $userService->getUserById($user1->id);
if ($user) {
echo "\n=== User by ID ===\n";
echo "Found: " . $user->getFullInfo() . "\n";
}
// Update user
$updatedUser = $userService->updateUser($user1->id, [
'name' => 'Alice Johnson',
'email' => '[email protected]',
'age' => 26
]);
echo "\n=== Updated User ===\n";
echo $updatedUser->getFullInfo() . "\n";
// Search
echo "\n=== Search Results ===\n";
$searchResults = $userService->searchUsers('Alice');
foreach ($searchResults as $user) {
echo "Found: " . $user->getFullInfo() . "\n";
}
// Age range filter
echo "\n=== Users by Age Range ===\n";
$ageFilteredUsers = $userService->getUsersByAgeRange(25, 35);
foreach ($ageFilteredUsers as $user) {
echo $user->getFullInfo() . "\n";
}
} catch (\Exception $e) {
echo "Error: " . $e->getMessage() . "\n";
}
}
Relationships and Queries
<?php
// Comment entity
class Comment extends Entity
{
protected static $table = 'comments';
public static function fields()
{
return [
'id' => ['type' => 'integer', 'primary' => true, 'autoincrement' => true],
'post_id' => ['type' => 'integer', 'required' => true],
'user_id' => ['type' => 'integer', 'required' => true],
'content' => ['type' => 'text', 'required' => true],
'approved' => ['type' => 'boolean', 'default' => false],
'created_at' => ['type' => 'datetime', 'value' => new \DateTime()],
'updated_at' => ['type' => 'datetime', 'value' => new \DateTime()]
];
}
public static function relations(MapperInterface $mapper, EntityInterface $entity)
{
return [
'post' => $mapper->belongsTo($entity, 'App\Entity\Post', 'post_id'),
'user' => $mapper->belongsTo($entity, 'App\Entity\User', 'user_id'),
];
}
}
// Blog service (relationship usage example)
class BlogService
{
private $spot;
public function __construct($spot)
{
$this->spot = $spot;
}
// Create post
public function createPost($userId, $title, $content)
{
$userMapper = $this->spot->mapper('App\Entity\User');
$user = $userMapper->get($userId);
if (!$user) {
throw new \InvalidArgumentException('User not found');
}
$postMapper = $this->spot->mapper('App\Entity\Post');
$post = $postMapper->create([
'title' => $title,
'content' => $content,
'user_id' => $userId,
'published' => false
]);
if ($post) {
echo "Post created: {$post->title} by {$user->name}\n";
return $post;
}
throw new \RuntimeException('Failed to create post');
}
// Get user posts
public function getUserPosts($userId)
{
$postMapper = $this->spot->mapper('App\Entity\Post');
return $postMapper->where(['user_id' => $userId])
->order(['created_at' => 'DESC']);
}
// Get posts with user information join
public function getPostsWithAuthors()
{
$postMapper = $this->spot->mapper('App\Entity\Post');
return $postMapper->all()
->with('user')
->order(['created_at' => 'DESC']);
}
// Get published posts
public function getPublishedPosts()
{
$postMapper = $this->spot->mapper('App\Entity\Post');
return $postMapper->where(['published' => true])
->with('user')
->order(['published_at' => 'DESC']);
}
// Publish post
public function publishPost($postId)
{
$postMapper = $this->spot->mapper('App\Entity\Post');
$post = $postMapper->get($postId);
if (!$post) {
throw new \InvalidArgumentException('Post not found');
}
$post->publish();
$result = $postMapper->save($post);
if ($result) {
echo "Post published: {$post->title}\n";
return $post;
}
throw new \RuntimeException('Failed to publish post');
}
// Add comment
public function addComment($postId, $userId, $content)
{
$postMapper = $this->spot->mapper('App\Entity\Post');
$userMapper = $this->spot->mapper('App\Entity\User');
$post = $postMapper->get($postId);
$user = $userMapper->get($userId);
if (!$post) {
throw new \InvalidArgumentException('Post not found');
}
if (!$user) {
throw new \InvalidArgumentException('User not found');
}
$commentMapper = $this->spot->mapper('App\Entity\Comment');
$comment = $commentMapper->create([
'post_id' => $postId,
'user_id' => $userId,
'content' => $content,
'approved' => false
]);
if ($comment) {
echo "Comment added by {$user->name} on post: {$post->title}\n";
return $comment;
}
throw new \RuntimeException('Failed to add comment');
}
// Get post comments
public function getPostComments($postId)
{
$commentMapper = $this->spot->mapper('App\Entity\Comment');
return $commentMapper->where(['post_id' => $postId])
->with('user')
->order(['created_at' => 'ASC']);
}
// Complex query: Popular users (by post count)
public function getPopularUsers($limit = 10)
{
$userMapper = $this->spot->mapper('App\Entity\User');
// Execute JOIN query with Spot
$query = $userMapper->query("
SELECT u.*, COUNT(p.id) as post_count
FROM users u
LEFT JOIN posts p ON u.id = p.user_id
GROUP BY u.id
ORDER BY post_count DESC
LIMIT {$limit}
");
return $query;
}
// Get statistics
public function getBlogStatistics()
{
$userMapper = $this->spot->mapper('App\Entity\User');
$postMapper = $this->spot->mapper('App\Entity\Post');
$commentMapper = $this->spot->mapper('App\Entity\Comment');
$stats = [
'total_users' => $userMapper->all()->count(),
'total_posts' => $postMapper->all()->count(),
'published_posts' => $postMapper->where(['published' => true])->count(),
'total_comments' => $commentMapper->all()->count(),
'approved_comments' => $commentMapper->where(['approved' => true])->count(),
];
return $stats;
}
}
// Usage example
function demonstrateRelationships()
{
$spot = require __DIR__ . '/config/database.php';
$userService = new UserService($spot);
$blogService = new BlogService($spot);
try {
// Comment table creation
$commentMapper = $spot->mapper('App\Entity\Comment');
$commentMapper->migrate();
// User creation
$user = $userService->createUser('John Doe', '[email protected]', 30);
// Post creation
$post = $blogService->createPost($user->id, 'My First Post', 'This is the content of my first post.');
// Publish post
$publishedPost = $blogService->publishPost($post->id);
// Add comment
$comment = $blogService->addComment($post->id, $user->id, 'Great post!');
// Get published posts with user information
echo "\n=== Published Posts with Authors ===\n";
$postsWithAuthors = $blogService->getPublishedPosts();
foreach ($postsWithAuthors as $post) {
echo "Post: {$post->title} by {$post->user->name}\n";
}
// Get post comments
echo "\n=== Post Comments ===\n";
$comments = $blogService->getPostComments($post->id);
foreach ($comments as $comment) {
echo "Comment by {$comment->user->name}: {$comment->content}\n";
}
// Statistics
echo "\n=== Blog Statistics ===\n";
$stats = $blogService->getBlogStatistics();
foreach ($stats as $key => $value) {
echo ucfirst(str_replace('_', ' ', $key)) . ": {$value}\n";
}
} catch (\Exception $e) {
echo "Error: " . $e->getMessage() . "\n";
}
}
Advanced Features and Customization
<?php
// Custom mapper
class UserMapper extends \Spot\Mapper
{
// Custom scope
public function active()
{
return $this->where(['active' => true]);
}
public function byAge($age)
{
return $this->where(['age' => $age]);
}
public function adults()
{
return $this->where(['age :gte' => 18]);
}
// Custom query method
public function findByEmailDomain($domain)
{
return $this->where(['email :like' => "%@{$domain}"]);
}
public function getUsersWithPostCount()
{
return $this->query("
SELECT u.*, COUNT(p.id) as post_count
FROM users u
LEFT JOIN posts p ON u.id = p.user_id
GROUP BY u.id
ORDER BY u.name
");
}
// Event hooks
public function beforeSave($entity, $options)
{
if ($entity->isNew()) {
$entity->created_at = new \DateTime();
}
$entity->updated_at = new \DateTime();
return true;
}
public function afterSave($entity, $options)
{
// Post-save processing (logging, cache clearing, etc.)
echo "User saved: {$entity->name}\n";
return true;
}
}
// Specify custom mapper for User entity
class User extends Entity
{
protected static $table = 'users';
protected static $mapper = 'UserMapper';
// ... Other methods remain the same
}
// Advanced service class
class AdvancedUserService
{
private $spot;
public function __construct($spot)
{
$this->spot = $spot;
}
// Transaction processing
public function transferUserData($fromUserId, $toUserId)
{
$connection = $this->spot->connection();
try {
$connection->beginTransaction();
$userMapper = $this->spot->mapper('App\Entity\User');
$postMapper = $this->spot->mapper('App\Entity\Post');
$fromUser = $userMapper->get($fromUserId);
$toUser = $userMapper->get($toUserId);
if (!$fromUser || !$toUser) {
throw new \InvalidArgumentException('One or both users not found');
}
// Transfer posts
$posts = $postMapper->where(['user_id' => $fromUserId]);
foreach ($posts as $post) {
$postMapper->update($post, ['user_id' => $toUserId]);
}
// Delete original user
$userMapper->delete($fromUser);
$connection->commit();
echo "User data transferred successfully from User {$fromUserId} to User {$toUserId}\n";
return true;
} catch (\Exception $e) {
$connection->rollback();
throw new \RuntimeException("Transfer failed: " . $e->getMessage());
}
}
// Batch processing
public function batchCreateUsers($usersData)
{
$userMapper = $this->spot->mapper('App\Entity\User');
$createdUsers = [];
$errors = [];
foreach ($usersData as $index => $userData) {
try {
$validationErrors = User::validate($userData);
if (!empty($validationErrors)) {
$errors[$index] = $validationErrors;
continue;
}
$user = $userMapper->create($userData);
if ($user) {
$createdUsers[] = $user;
} else {
$errors[$index] = 'Failed to create user';
}
} catch (\Exception $e) {
$errors[$index] = $e->getMessage();
}
}
return [
'created' => $createdUsers,
'errors' => $errors,
'success_count' => count($createdUsers),
'error_count' => count($errors)
];
}
// Conditional update
public function updateUsersByCondition($conditions, $updates)
{
$userMapper = $this->spot->mapper('App\Entity\User');
$users = $userMapper->where($conditions);
$updatedCount = 0;
$errors = [];
foreach ($users as $user) {
try {
$result = $userMapper->update($user, $updates);
if ($result) {
$updatedCount++;
}
} catch (\Exception $e) {
$errors[] = "Failed to update user {$user->id}: " . $e->getMessage();
}
}
return [
'updated_count' => $updatedCount,
'errors' => $errors
];
}
// Data export
public function exportUsersToJson($conditions = [])
{
$userMapper = $this->spot->mapper('App\Entity\User');
$query = empty($conditions) ? $userMapper->all() : $userMapper->where($conditions);
$users = $query->with('posts', 'profile');
$exportData = [];
foreach ($users as $user) {
$userData = [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'age' => $user->age,
'active' => $user->active,
'created_at' => $user->created_at->format('Y-m-d H:i:s'),
'posts' => []
];
if ($user->posts) {
foreach ($user->posts as $post) {
$userData['posts'][] = [
'id' => $post->id,
'title' => $post->title,
'published' => $post->published,
'created_at' => $post->created_at->format('Y-m-d H:i:s')
];
}
}
if ($user->profile) {
$userData['profile'] = [
'bio' => $user->profile->bio,
'website' => $user->profile->website,
'location' => $user->profile->location
];
}
$exportData[] = $userData;
}
return json_encode($exportData, JSON_PRETTY_PRINT);
}
// Cache functionality (simple version)
private $cache = [];
public function getUserWithCache($id)
{
if (isset($this->cache["user_{$id}"])) {
echo "Retrieved from cache: User {$id}\n";
return $this->cache["user_{$id}"];
}
$userMapper = $this->spot->mapper('App\Entity\User');
$user = $userMapper->get($id);
if ($user) {
$this->cache["user_{$id}"] = $user;
echo "Cached: User {$id}\n";
}
return $user;
}
public function clearUserCache($id = null)
{
if ($id) {
unset($this->cache["user_{$id}"]);
} else {
$this->cache = [];
}
}
}
// Usage example
function demonstrateAdvancedFeatures()
{
$spot = require __DIR__ . '/config/database.php';
$advancedService = new AdvancedUserService($spot);
try {
// Batch user creation
$usersData = [
['name' => 'User 1', 'email' => '[email protected]', 'age' => 25],
['name' => 'User 2', 'email' => '[email protected]', 'age' => 30],
['name' => '', 'email' => 'invalid', 'age' => -5], // Validation error
['name' => 'User 4', 'email' => '[email protected]', 'age' => 35],
];
echo "=== Batch User Creation ===\n";
$batchResult = $advancedService->batchCreateUsers($usersData);
echo "Created: {$batchResult['success_count']}, Errors: {$batchResult['error_count']}\n";
if (!empty($batchResult['errors'])) {
foreach ($batchResult['errors'] as $index => $error) {
echo "Error at index {$index}: " . (is_array($error) ? implode(', ', $error) : $error) . "\n";
}
}
// Conditional update
echo "\n=== Conditional Update ===\n";
$updateResult = $advancedService->updateUsersByCondition(
['age :gte' => 30],
['active' => false]
);
echo "Updated {$updateResult['updated_count']} users\n";
// Data export
echo "\n=== Data Export ===\n";
$jsonData = $advancedService->exportUsersToJson(['active' => true]);
echo "Export data (first 200 chars): " . substr($jsonData, 0, 200) . "...\n";
// Cache test
echo "\n=== Cache Test ===\n";
$user1 = $advancedService->getUserWithCache(1);
$user2 = $advancedService->getUserWithCache(1); // Retrieved from cache
} catch (\Exception $e) {
echo "Error: " . $e->getMessage() . "\n";
}
}
// Main execution
function main()
{
echo "=== Spot ORM Demo ===\n\n";
demonstrateBasicOperations();
echo "\n" . str_repeat("=", 50) . "\n\n";
demonstrateRelationships();
echo "\n" . str_repeat("=", 50) . "\n\n";
demonstrateAdvancedFeatures();
}
if (php_sapi_name() === 'cli') {
main();
}