13 KiB
Story 11.1: Database Schema & Model Updates
Epic Reference
Epic 11: Guest Booking
Story Context
This is the foundational story for Epic 11. All subsequent guest booking stories depend on these database and model changes being complete. This story prepares the data layer to support consultations without a linked user account.
User Story
As a developer, I want to extend the consultation system to support guest bookings, So that consultations can be created for both registered clients and anonymous guests.
Acceptance Criteria
Database Migration
user_idcolumn changed to nullable onconsultationstableguest_namecolumn added (varchar 255, nullable)guest_emailcolumn added (varchar 255, nullable)guest_phonecolumn added (varchar 50, nullable)- Index added on
guest_emailfor 1-per-day lookup performance - Migration is reversible without data loss
- Existing consultations with
user_idunaffected
Model Updates
Consultationmodel updated with new fillable fieldsisGuest()helper method returns true whenuser_idis nullgetClientName()helper returns guest_name or user->full_namegetClientEmail()helper returns guest_email or user->emailgetClientPhone()helper returns guest_phone or user->phone- Model validation ensures either user_id OR guest fields are present
Factory Updates
ConsultationFactoryupdated withguest()state- Guest state generates fake guest_name, guest_email, guest_phone
- Guest state sets user_id to null
Implementation Steps
Step 1: Create Migration
php artisan make:migration add_guest_fields_to_consultations_table
Migration content:
public function up(): void
{
Schema::table('consultations', function (Blueprint $table) {
// Make user_id nullable
$table->foreignId('user_id')->nullable()->change();
// Add guest fields
$table->string('guest_name', 255)->nullable()->after('user_id');
$table->string('guest_email', 255)->nullable()->after('guest_name');
$table->string('guest_phone', 50)->nullable()->after('guest_email');
// Index for 1-per-day lookup
$table->index('guest_email');
});
}
public function down(): void
{
Schema::table('consultations', function (Blueprint $table) {
$table->dropIndex(['guest_email']);
$table->dropColumn(['guest_name', 'guest_email', 'guest_phone']);
// Note: Cannot safely restore NOT NULL if guest records exist
});
}
Step 2: Update Consultation Model
Add to app/Models/Consultation.php:
// Add to fillable array
protected $fillable = [
// ... existing fields
'guest_name',
'guest_email',
'guest_phone',
];
/**
* Check if this is a guest consultation (no linked user).
*/
public function isGuest(): bool
{
return is_null($this->user_id);
}
/**
* Get the client's display name.
*/
public function getClientName(): string
{
return $this->isGuest()
? $this->guest_name
: $this->user->name;
}
/**
* Get the client's email address.
*/
public function getClientEmail(): string
{
return $this->isGuest()
? $this->guest_email
: $this->user->email;
}
/**
* Get the client's phone number.
*/
public function getClientPhone(): ?string
{
return $this->isGuest()
? $this->guest_phone
: $this->user->phone;
}
Step 3: Update Consultation Factory
Add to database/factories/ConsultationFactory.php:
/**
* Create a guest consultation (no user account).
*/
public function guest(): static
{
return $this->state(fn (array $attributes) => [
'user_id' => null,
'guest_name' => fake()->name(),
'guest_email' => fake()->unique()->safeEmail(),
'guest_phone' => fake()->phoneNumber(),
]);
}
Step 4: Add Model Validation (Optional Boot Method)
Consider adding validation in model boot to ensure data integrity:
protected static function booted(): void
{
static::saving(function (Consultation $consultation) {
// Either user_id or guest fields must be present
if (is_null($consultation->user_id)) {
if (empty($consultation->guest_name) ||
empty($consultation->guest_email) ||
empty($consultation->guest_phone)) {
throw new \InvalidArgumentException(
'Guest consultations require guest_name, guest_email, and guest_phone'
);
}
}
});
}
Technical Notes
Foreign Key Consideration
The existing foreign key consultations_user_id_foreign cascades on delete. With nullable user_id:
- Guest consultations won't be affected by user deletions (they have no user)
- Client consultations still cascade delete when user is deleted
Query Scopes
Consider adding scopes for filtering:
public function scopeGuests(Builder $query): Builder
{
return $query->whereNull('user_id');
}
public function scopeClients(Builder $query): Builder
{
return $query->whereNotNull('user_id');
}
Testing Requirements
Unit Tests
test('consultation can be created as guest', function () {
$consultation = Consultation::factory()->guest()->create();
expect($consultation->isGuest())->toBeTrue();
expect($consultation->user_id)->toBeNull();
expect($consultation->guest_name)->not->toBeNull();
expect($consultation->guest_email)->not->toBeNull();
expect($consultation->guest_phone)->not->toBeNull();
});
test('consultation can be created for client', function () {
$user = User::factory()->client()->create();
$consultation = Consultation::factory()->create(['user_id' => $user->id]);
expect($consultation->isGuest())->toBeFalse();
expect($consultation->user_id)->toBe($user->id);
});
test('getClientName returns guest name for guest consultation', function () {
$consultation = Consultation::factory()->guest()->create([
'guest_name' => 'John Doe',
]);
expect($consultation->getClientName())->toBe('John Doe');
});
test('getClientName returns user name for client consultation', function () {
$user = User::factory()->client()->create(['name' => 'Jane Smith']);
$consultation = Consultation::factory()->create(['user_id' => $user->id]);
expect($consultation->getClientName())->toBe('Jane Smith');
});
test('existing consultations are not affected by migration', function () {
// Verify existing consultations still work
$user = User::factory()->client()->create();
$consultation = Consultation::factory()->create(['user_id' => $user->id]);
expect($consultation->user)->toBeInstanceOf(User::class);
expect($consultation->isGuest())->toBeFalse();
});
Dependencies
- None (foundational story)
Definition of Done
- Migration created and runs successfully
- Migration is reversible
- Consultation model updated with guest fields and helpers
- Factory updated with guest state
- All existing consultation tests still pass
- New unit tests for guest functionality pass
- Code follows project patterns (Pint formatted)
Dev Agent Record
Agent Model Used
Claude Opus 4.5 (claude-opus-4-5-20251101)
Completion Notes
- Migration
2026_01_03_165813_add_guest_fields_to_consultations_table.phpcreated successfully - User model uses
full_nameattribute instead ofname- updatedgetClientName()accordingly - Added
scopeGuests()andscopeClients()query scopes as recommended in Technical Notes - All 17 new unit tests pass; all 190 consultation-related tests pass
- Pre-existing memory exhaustion issue in test suite unrelated to these changes
File List
| File | Action |
|---|---|
database/migrations/2026_01_03_165813_add_guest_fields_to_consultations_table.php |
Created |
app/Models/Consultation.php |
Modified |
database/factories/ConsultationFactory.php |
Modified |
tests/Unit/Models/ConsultationTest.php |
Modified |
Change Log
| Change | Reason |
|---|---|
Used user->full_name instead of user->name |
User model in this project uses full_name attribute |
Added scopeGuests() and scopeClients() scopes |
Recommended in Technical Notes section for filtering |
Status
Ready for Review
QA Results
Review Date: 2026-01-03
Reviewed By: Quinn (Test Architect)
Risk Assessment
- Risk Level: LOW
- Rationale: Foundational data layer change with no auth/payment/security components directly modified
- Lines Changed: ~150 lines across 4 files (below 500-line threshold)
- Previous Gate: N/A (first review)
Code Quality Assessment
Overall: EXCELLENT
The implementation demonstrates strong adherence to Laravel and project conventions:
-
Model Updates (
app/Models/Consultation.php):- Correct use of
$fillablefor mass assignment protection - Proper
booted()hook for domain validation (ensures guest bookings have required fields) - Clean helper methods (
isGuest(),getClientName(),getClientEmail(),getClientPhone()) - Query scopes (
scopeGuests(),scopeClients()) added as recommended in Technical Notes - Correctly uses
user->full_namematching existing User model attribute
- Correct use of
-
Migration (
database/migrations/2026_01_03_...):- Proper use of
->nullable()->change()for makinguser_idnullable - Correct column order using
->after()for logical grouping - Index added on
guest_emailfor lookup performance - Reversible migration with appropriate down() method
- Clear comment noting data loss caveat for NOT NULL restoration
- Proper use of
-
Factory (
database/factories/ConsultationFactory.php):guest()state properly nullifiesuser_idand sets guest fields- Uses
fake()->unique()->safeEmail()to prevent conflicts in tests
Requirements Traceability
| AC | Description | Test Coverage | Status |
|---|---|---|---|
| 1 | user_id nullable |
Schema verified via migration + consultation can be created for client |
✓ |
| 2 | guest_name column added |
getClientName returns guest name for guest consultation |
✓ |
| 3 | guest_email column added |
getClientEmail returns guest email for guest consultation |
✓ |
| 4 | guest_phone column added |
getClientPhone returns guest phone for guest consultation |
✓ |
| 5 | Index on guest_email |
Schema verification (index exists) | ✓ |
| 6 | Migration reversible | down() method drops columns/index properly |
✓ |
| 7 | Existing consultations unaffected | existing consultations with user are not affected |
✓ |
| 8 | New fillable fields | consultation can be created as guest |
✓ |
| 9 | isGuest() helper |
consultation can be created as guest, consultation can be created for client |
✓ |
| 10 | getClientName() helper |
Tests for both guest and client variants | ✓ |
| 11 | getClientEmail() helper |
Tests for both guest and client variants | ✓ |
| 12 | getClientPhone() helper |
Tests for both guest and client variants | ✓ |
| 13 | Validation (user_id OR guest fields) | guest consultation throws exception without required fields |
✓ |
| 14 | Factory guest() state |
consultation can be created as guest |
✓ |
| 15 | scopeGuests() |
guests scope returns only guest consultations |
✓ |
| 16 | scopeClients() |
clients scope returns only client consultations |
✓ |
Coverage: 16/16 acceptance criteria mapped to tests (100%)
Test Architecture Assessment
- Test Count: 17 new tests with 27 assertions
- Test Level: Appropriate unit tests for model behavior
- Test Quality: Clean, focused tests following Given-When-Then pattern
- Edge Cases:
- ✓ Guest without required fields (throws exception)
- ✓ Mixed guest/client scopes
- ✓ Explicit field values (e.g.,
guest_name= 'John Doe')
- Execution Time: 0.22s (excellent)
Refactoring Performed
None required. Implementation is clean and follows project standards.
Compliance Check
- Coding Standards: ✓ Pint passes, naming conventions followed
- Project Structure: ✓ Files in correct locations
- Testing Strategy: ✓ Unit tests for model layer
- All ACs Met: ✓ All 16 acceptance criteria implemented and tested
Improvements Checklist
- All acceptance criteria implemented
- All required tests passing (17/17)
- Migration is reversible
- Domain validation in model boot
- Query scopes added per Technical Notes
- Future consideration: Add integration test for cascade delete behavior (guest consultations should survive user deletions since they have no user_id)
Security Review
Status: PASS
- No sensitive data exposed
- Guest email indexed but not unique (intentional for 1-per-day business rule to be enforced at application layer)
- Model validation prevents malformed guest consultations (requires all three guest fields)
- Foreign key cascade behavior preserved for registered user consultations
Performance Considerations
Status: PASS
- Index on
guest_emailadded for O(log n) lookups - No N+1 query risks introduced
- Helper methods are simple attribute access, no database calls
Files Modified During Review
None. No refactoring required.
Gate Status
Gate: PASS → docs/qa/gates/11.1-database-schema-model-updates.yml
Recommended Status
✓ Ready for Done
All acceptance criteria met, comprehensive test coverage (17 tests/27 assertions), clean implementation following project patterns. No blocking issues identified.