├── resources └── views │ └── .gitkeep ├── phpstan.neon ├── .gitignore ├── tests ├── Pest.php ├── Datasets │ └── SdkTypes.php ├── TestCase.php └── Unit │ └── Api │ ├── AssociationTest.php │ └── ModelTest.php ├── src ├── Api │ ├── Concerns │ │ ├── HasPropertyDefinitions.php │ │ └── HasAssociations.php │ ├── Interfaces │ │ └── ModelInterface.php │ ├── Collection.php │ ├── PropertyDefinition.php │ ├── Association.php │ ├── Filter.php │ ├── Client.php │ ├── Model.php │ └── Builder.php ├── Crm │ ├── Ticket.php │ ├── Product.php │ ├── Meeting.php │ ├── Note.php │ ├── Call.php │ ├── Company.php │ ├── LineItems.php │ ├── Contact.php │ ├── FeedbackSubmissions.php │ ├── Email.php │ ├── Task.php │ ├── Deal.php │ ├── Quote.php │ ├── Invoice.php │ ├── Subscription.php │ ├── Owner.php │ └── Property.php ├── Exceptions │ ├── InvalidRequestException.php │ ├── NotFoundException.php │ └── RateLimitException.php ├── Facades │ └── HubSpot.php ├── HubSpotServiceProvider.php └── Sdk.php ├── database ├── factories │ └── ModelFactory.php └── migrations │ └── create_skeleton_table.php.stub ├── .github └── workflows │ ├── phpstan.yml │ └── run-tests.yml ├── LICENSE.md ├── phpunit.xml.dist ├── composer.json ├── config └── hubspot.php ├── README.md └── configure.php /resources/views/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 2 3 | paths: 4 | - src/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | composer.lock 3 | docs 4 | vendor 5 | coverage 6 | .idea 7 | .phpunit* -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- 1 | in(__DIR__); 6 | -------------------------------------------------------------------------------- /src/Api/Concerns/HasPropertyDefinitions.php: -------------------------------------------------------------------------------- 1 | id(); 13 | 14 | // add fields 15 | 16 | $table->timestamps(); 17 | }); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /src/Crm/Contact.php: -------------------------------------------------------------------------------- 1 | name('hubspot')->hasConfigFile(); 14 | 15 | $this->app->bind(Client::class, fn() => new Client(config('hubspot.access_token'))); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Crm/FeedbackSubmissions.php: -------------------------------------------------------------------------------- 1 | effectiveUri()->getPath(), 17 | $response->status(), 18 | $previous 19 | ); 20 | 21 | $this->response = $response; 22 | } 23 | } -------------------------------------------------------------------------------- /src/Exceptions/RateLimitException.php: -------------------------------------------------------------------------------- 1 | json('message', "HTTP request returned status code {$response->status()}"), 17 | $response->status(), 18 | $previous 19 | ); 20 | 21 | $this->response = $response; 22 | } 23 | } -------------------------------------------------------------------------------- /.github/workflows/phpstan.yml: -------------------------------------------------------------------------------- 1 | name: PHPStan 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | phpstan: 11 | name: phpstan 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Setup PHP 17 | uses: shivammathur/setup-php@v2 18 | with: 19 | php-version: '8.2' 20 | coverage: none 21 | 22 | - uses: "ramsey/composer-install@v2" 23 | 24 | - name: Install composer dependencies (need autoloader built) 25 | run: | 26 | composer install --no-scripts 27 | 28 | - name: Run PHPStan 29 | run: ./vendor/bin/phpstan --error-format=github 30 | -------------------------------------------------------------------------------- /src/Crm/Deal.php: -------------------------------------------------------------------------------- 1 | getValue($sdk) as $key => $modelClass) { 10 | yield $key; 11 | } 12 | }); 13 | 14 | dataset('SdkTypes-singular', function () { 15 | $sdk = new Sdk(); 16 | $properties = new ReflectionProperty($sdk, 'models'); 17 | foreach ($properties->getValue($sdk) as $key => $modelClass) { 18 | yield Str::singular($key); 19 | } 20 | }); 21 | 22 | dataset('SdkTypes-both', function () { 23 | $sdk = new Sdk(); 24 | $properties = new ReflectionProperty($sdk, 'models'); 25 | foreach ($properties->getValue($sdk) as $key => $modelClass) { 26 | yield $key; 27 | yield Str::singular($key); 28 | } 29 | }); 30 | -------------------------------------------------------------------------------- /src/Crm/Owner.php: -------------------------------------------------------------------------------- 1 | 'string', 16 | 'firstName' => 'string', 17 | 'lastName' => 'string', 18 | 'userId' => 'integer', 19 | 'groupName' => 'string', 20 | 'options' => 'array', 21 | 'createdAt' => 'datetime', 22 | 'updatedAt' => 'datetime', 23 | 'archived' => 'bool', 24 | 'archivedAt' => 'datetime', 25 | ]; 26 | 27 | protected array $endpoints = [ 28 | "read" => "/v3/owners/{id}", 29 | ]; 30 | 31 | protected function init(array $payload = []): static 32 | { 33 | $this->payload = $payload; 34 | $this->fill($payload); 35 | $this->exists = true; 36 | 37 | return $this; 38 | } 39 | } -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | 'STS\\HubSpot\\Database\\Factories\\'.class_basename($modelName).'Factory' 17 | ); 18 | } 19 | 20 | protected function getPackageProviders($app): array 21 | { 22 | return [ 23 | HubSpotServiceProvider::class, 24 | ]; 25 | } 26 | 27 | public function getEnvironmentSetUp($app) 28 | { 29 | config()->set('database.default', 'testing'); 30 | 31 | /* 32 | $migration = include __DIR__.'/../database/migrations/create_skeleton_table.php.stub'; 33 | $migration->up(); 34 | */ 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Api/Collection.php: -------------------------------------------------------------------------------- 1 | map(fn($payload) => $className::hydrate($payload)); 18 | $instance->response = $response; 19 | $instance->total = Arr::get($response, 'total', 0); 20 | $instance->after = Arr::get($response, 'paging.next.after'); 21 | 22 | return $instance; 23 | } 24 | 25 | public function total(): int 26 | { 27 | return $this->total; 28 | } 29 | 30 | public function response(): array 31 | { 32 | return $this->response; 33 | } 34 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Signature Tech Studio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 21 | 22 | 23 | tests 24 | 25 | 26 | 27 | 28 | ./src 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: run-tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | fail-fast: true 14 | matrix: 15 | os: [ ubuntu-latest, windows-latest ] 16 | php: [ 8.2, 8.1 ] 17 | laravel: [ 10.*, 9.* ] 18 | stability: [ prefer-lowest, prefer-stable ] 19 | include: 20 | - laravel: 10.* 21 | testbench: 8.* 22 | carbon: ^2.63 23 | - laravel: 9.* 24 | testbench: ^7.0 25 | carbon: ^2.63 26 | 27 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} 28 | 29 | steps: 30 | - name: Checkout code 31 | uses: actions/checkout@v3 32 | 33 | - name: Setup PHP 34 | uses: shivammathur/setup-php@v2 35 | with: 36 | php-version: ${{ matrix.php }} 37 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, intl, iconv, fileinfo 38 | coverage: none 39 | 40 | - name: Setup problem matchers 41 | run: | 42 | echo "::add-matcher::${{ runner.tool_cache }}/php.json" 43 | echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" 44 | 45 | - name: Install dependencies 46 | run: | 47 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" "nesbot/carbon:${{ matrix.carbon }}" --no-interaction --no-update 48 | composer update --${{ matrix.stability }} --prefer-dist --no-interaction 49 | 50 | - name: List Installed Dependencies 51 | run: composer show -D 52 | 53 | - name: Execute tests 54 | run: vendor/bin/pest 55 | -------------------------------------------------------------------------------- /src/Api/PropertyDefinition.php: -------------------------------------------------------------------------------- 1 | get()->get($key); 23 | } 24 | 25 | return $this->collection ?? $this->load(); 26 | } 27 | 28 | public function create(array $properties = []): Property 29 | { 30 | return tap( 31 | $this->builder->createDefinition($properties), 32 | fn() => $this->refresh() 33 | ); 34 | } 35 | 36 | public function load(): Collection 37 | { 38 | return $this->cache()->has($this->cacheKey()) 39 | ? $this->cache()->get($this->cacheKey()) 40 | : $this->refresh(); 41 | } 42 | 43 | public function refresh(): Collection 44 | { 45 | $definitions = $this->builder->properties()->keyBy('name'); 46 | 47 | if(HubSpot::shouldCacheDefinitions()) { 48 | $this->cache()->put($this->cacheKey(), $definitions, HubSpot::definitionCacheTtl()); 49 | } 50 | 51 | return $definitions; 52 | } 53 | 54 | protected function cache(): Repository 55 | { 56 | // TODO: consider using tagged cache if available 57 | return Cache::driver(); 58 | } 59 | 60 | protected function cacheKey(): string 61 | { 62 | return "hubspot.{$this->object->type()}.definitions"; 63 | } 64 | } -------------------------------------------------------------------------------- /src/Api/Concerns/HasAssociations.php: -------------------------------------------------------------------------------- 1 | preloaded = $preloaded; 18 | 19 | return $this; 20 | } 21 | 22 | public function associations($type): Association 23 | { 24 | if(!$this->associationsLoaded($type)) { 25 | $this->associations[$type] = new Association($this, HubSpot::factory($type), $this->getAssociationIDs($type)); 26 | } 27 | 28 | return $this->associations[$type]; 29 | } 30 | 31 | public function getAssociations($type): Collection 32 | { 33 | return $this->associations($type)->get(); 34 | } 35 | 36 | public function associationsLoaded($type): bool 37 | { 38 | return isset($this->associations) && array_key_exists($type, $this->associations); 39 | } 40 | 41 | public function loadAssociations($type): Collection 42 | { 43 | return $this->associations($type)->load(); 44 | } 45 | 46 | protected function getAssociationIDs($type): array 47 | { 48 | $results = in_array($type, $this->preloaded) 49 | ? Arr::get($this->payload, "associations.$type.results", []) 50 | : $this->loadAssociationIDs($type); 51 | 52 | return Arr::pluck($results, 'id'); 53 | } 54 | 55 | protected function loadAssociationIDs($type): array 56 | { 57 | $this->preloaded[] = $type; 58 | $results = $this->builder()->associations($type); 59 | Arr::set($this->payload, "associations.$type.results", $results); 60 | 61 | return $results; 62 | } 63 | } -------------------------------------------------------------------------------- /src/Api/Association.php: -------------------------------------------------------------------------------- 1 | collection ?? $this->load(); 24 | } 25 | 26 | public function load(): Collection 27 | { 28 | $this->collection = $this->builder()->findMany($this->ids); 29 | 30 | return $this->collection; 31 | } 32 | 33 | public function create(array $properties = []): Model 34 | { 35 | $instance = $this->target->create($properties); 36 | $this->target = $instance; 37 | 38 | $this->attach($this->target->id); 39 | 40 | return $this->target; 41 | } 42 | 43 | public function attach($targetId) 44 | { 45 | if($targetId instanceof Model) { 46 | $targetId = $targetId->id; 47 | } 48 | 49 | $this->sourceBuilder()->associate( 50 | $this->target, $targetId 51 | ); 52 | } 53 | 54 | public function detach($targetId) 55 | { 56 | if ($targetId instanceof Model) { 57 | $targetId = $targetId->id; 58 | } 59 | 60 | $this->sourceBuilder()->deleteAssociation( 61 | $this->target, $targetId 62 | ); 63 | } 64 | 65 | public function sourceBuilder(): Builder 66 | { 67 | return $this->source->builder(); 68 | } 69 | 70 | public function builder(): Builder 71 | { 72 | return $this->target->builder(); 73 | } 74 | 75 | public function __call($method, $parameters) 76 | { 77 | return $this->forwardCallTo($this->builder(), $method, $parameters); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Sdk.php: -------------------------------------------------------------------------------- 1 | Company::class, 27 | 'contacts' => Contact::class, 28 | 'deals' => Deal::class, 29 | 'feedback_submissions' => FeedbackSubmissions::class, 30 | 'line_items' => LineItems::class, 31 | 'products' => Product::class, 32 | 'tickets' => Ticket::class, 33 | 'quotes' => Quote::class, 34 | 'calls' => Call::class, 35 | 'emails' => Email::class, 36 | 'meetings' => Meeting::class, 37 | 'notes' => Note::class, 38 | 'tasks' => Task::class, 39 | 'invoices' => Invoice::class, 40 | 'subscriptions' => Subscription::class, 41 | ]; 42 | 43 | public function __construct() 44 | { 45 | } 46 | 47 | public function isType($type) 48 | { 49 | return array_key_exists($type, $this->models); 50 | } 51 | 52 | public function getModel($type) 53 | { 54 | return $this->models[$type]; 55 | } 56 | 57 | public function factory($type): Model 58 | { 59 | $class = $this->getModel($type); 60 | 61 | return new $class; 62 | } 63 | 64 | public function shouldCacheDefinitions(): bool 65 | { 66 | return config('hubspot.definitions.cache') !== false; 67 | } 68 | 69 | public function definitionCacheTtl(): Carbon 70 | { 71 | return now()->add(config('hubspot.definitions.cache')); 72 | } 73 | } -------------------------------------------------------------------------------- /src/Crm/Property.php: -------------------------------------------------------------------------------- 1 | 'string', 18 | 'label' => 'string', 19 | 'type' => 'string', 20 | 'fieldType' => 'string', 21 | 'description' => 'string', 22 | 'groupName' => 'string', 23 | 'options' => 'array', 24 | 'createdAt' => 'datetime', 25 | 'updatedAt' => 'datetime', 26 | 'archived' => 'bool', 27 | 'archivedAt' => 'datetime', 28 | ]; 29 | 30 | public function scopeLoad(Builder $builder, $targetType) 31 | { 32 | return HubSpot::factory($targetType)->properties(); 33 | } 34 | 35 | protected function init(array $payload = []): static 36 | { 37 | $this->payload = $payload; 38 | $this->fill($payload); 39 | $this->exists = true; 40 | 41 | return $this; 42 | } 43 | 44 | public function unserialize($value): mixed 45 | { 46 | return match ($this->payload['type']) { 47 | 'date' => Carbon::parse($value), 48 | 'datetime' => Carbon::parse($value), 49 | 'number' => $value + 0, 50 | 'enumeration' => is_null($value) ? [] : explode(";", $value), 51 | default => $value, 52 | }; 53 | } 54 | 55 | public function serialize($value): mixed 56 | { 57 | return match ($this->payload['type']) { 58 | 'date' => $value instanceof Carbon ? $value->toIso8601String() : $value, 59 | 'dateTime' => $value instanceof Carbon ? $value->format('Y-m-d') : $value, 60 | 'number' => $value + 0, 61 | 'enumeration' => implode(';', $value), 62 | default => $value, 63 | }; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stechstudio/laravel-hubspot", 3 | "description": "A Laravel SDK for the HubSpot CRM Api", 4 | "keywords": [ 5 | "stechstudio", 6 | "laravel", 7 | "hubspot" 8 | ], 9 | "homepage": "https://github.com/stechstudio/laravel-hubspot", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Joseph Szobody", 14 | "email": "joseph@stechstudio.com", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.1", 20 | "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0", 21 | "illuminate/http": "^9.0|^10.0|^11.0|^12.0", 22 | "spatie/laravel-package-tools": "^1.14.0" 23 | }, 24 | "require-dev": { 25 | "laravel/pint": "^1.5", 26 | "nunomaduro/collision": "^6.4", 27 | "larastan/larastan": "^2.4.1", 28 | "orchestra/testbench": "^7.22", 29 | "pestphp/pest": "^1.22", 30 | "pestphp/pest-plugin-laravel": "^1.1", 31 | "pestphp/pest-plugin-parallel": "^1.2", 32 | "phpstan/extension-installer": "^1.1", 33 | "phpstan/phpstan-deprecation-rules": "^1.1", 34 | "phpstan/phpstan-phpunit": "^1.3", 35 | "phpunit/phpunit": "^9.6", 36 | "spatie/laravel-ray": "^1.32" 37 | }, 38 | "autoload": { 39 | "psr-4": { 40 | "STS\\HubSpot\\": "src", 41 | "STS\\HubSpot\\Database\\Factories\\": "database/factories" 42 | } 43 | }, 44 | "autoload-dev": { 45 | "psr-4": { 46 | "STS\\HubSpot\\Tests\\": "tests" 47 | } 48 | }, 49 | "scripts": { 50 | "analyse": "vendor/bin/phpstan analyse", 51 | "test": "vendor/bin/pest", 52 | "test-coverage": "vendor/bin/pest --coverage", 53 | "format": "vendor/bin/pint" 54 | }, 55 | "config": { 56 | "sort-packages": true, 57 | "allow-plugins": { 58 | "pestphp/pest-plugin": true, 59 | "phpstan/extension-installer": true 60 | } 61 | }, 62 | "extra": { 63 | "laravel": { 64 | "providers": [ 65 | "STS\\HubSpot\\HubSpotServiceProvider" 66 | ], 67 | "aliases": { 68 | "HubSpot": "STS\\HubSpot\\Facades\\HubSpot" 69 | } 70 | } 71 | }, 72 | "minimum-stability": "dev", 73 | "prefer-stable": true 74 | } 75 | -------------------------------------------------------------------------------- /src/Api/Filter.php: -------------------------------------------------------------------------------- 1 | property = $property; 18 | $this->endValue = $endValue; 19 | 20 | if ($value === null && !in_array($this->translateOperator($operator), ['HAS_PROPERTY', 'NOT_HAS_PROPERTY'])) { 21 | $this->operator = "EQ"; 22 | $this->value = $operator; 23 | } else { 24 | $this->operator = $this->translateOperator($operator); 25 | $this->value = $value; 26 | } 27 | } 28 | 29 | public function toArray() 30 | { 31 | if ($this->operator === 'BETWEEN') { 32 | return [ 33 | 'propertyName' => $this->property, 34 | 'operator' => $this->operator, 35 | 'highValue' => $this->value[0], 36 | 'value' => $this->value[1], 37 | ]; 38 | } 39 | 40 | if ($this->operator === 'IN' || $this->operator === 'NOT_IN') { 41 | return [ 42 | 'propertyName' => $this->property, 43 | 'operator' => $this->operator, 44 | 'values' => $this->value, 45 | ]; 46 | } 47 | 48 | return array_filter([ 49 | 'propertyName' => $this->property, 50 | 'operator' => $this->operator, 51 | 'value' => $this->cast($this->value) 52 | ]); 53 | } 54 | 55 | protected function cast($value = null) 56 | { 57 | if ($value instanceof Carbon) { 58 | return $value->timestamp; 59 | } 60 | 61 | return $value; 62 | } 63 | 64 | protected function translateOperator($operator): string 65 | { 66 | return Arr::get([ 67 | '=' => 'EQ', 68 | '!=' => 'NEQ', 69 | '<' => 'LT', 70 | '<=' => 'LTE', 71 | '>' => 'GT', 72 | '>=' => 'GTE', 73 | 'exists' => 'HAS_PROPERTY', 74 | 'not exists' => 'NOT_HAS_PROPERTY', 75 | 'like' => 'CONTAINS_TOKEN', 76 | 'not like' => 'NOT_CONTAINS_TOKEN' 77 | ], strtolower($operator), $operator); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /config/hubspot.php: -------------------------------------------------------------------------------- 1 | env('HUBSPOT_ACCESS_TOKEN'), 4 | 5 | 'definitions' => [ 6 | 'cache' => '30 days', 7 | ], 8 | 9 | 'contacts' => [ 10 | 'include_properties' => ['firstname','lastname','email'], 11 | 'include_associations' => ['companies','contact', 'deals','tickets'], 12 | ], 13 | 14 | 'companies' => [ 15 | 'include_properties' => ['domain','name','phone'], 16 | 'include_associations' => ['contacts','deals','tickets'], 17 | ], 18 | 19 | 'deals' => [ 20 | 'include_properties' => ['dealname','amount','closedate','pipeline','dealstage'], 21 | 'include_associations' => ['companies','contacts','tickets'], 22 | ], 23 | 24 | 'products' => [ 25 | 'include_properties' => ['name','description','price'], 26 | 'include_associations' => ['companies','contacts','deals','tickets'], 27 | ], 28 | 29 | 'tickets' => [ 30 | 'include_properties' => ['content','subject'], 31 | 'include_associations' => ['companies','contacts','deals'], 32 | ], 33 | 34 | 'line_items' => [ 35 | 'include_properties' => ['quantity','amount','price'], 36 | 'include_associations' => ['companies','contacts','deals','tickets'], 37 | ], 38 | 39 | 'quotes' => [ 40 | 'include_properties' => [], 41 | 'include_associations' => ['companies','contacts','deals','tickets'], 42 | ], 43 | 44 | 'invoices' => [ 45 | 'include_properties' => [], 46 | 'include_associations' => ['companies','contacts','quotes'], 47 | ], 48 | 49 | 'subscriptions' => [ 50 | 'include_properties' => [], 51 | 'include_associations' => ['companies','contacts','quotes'], 52 | ], 53 | 54 | 'calls' => [ 55 | 'include_properties' => ['hs_call_title','hubspot_owner_id','hs_call_body','hs_call_direction','hs_call_callee_object_id','hs_call_callee_object_type_id','hs_call_disposition','hs_call_duration','hs_call_from_number','hs_call_to_number'], 56 | 'include_associations' => ['companies','contacts','deals','tickets'], 57 | ], 58 | 59 | 'emails' => [ 60 | 'include_properties' => [ 61 | 'hubspot_owner_id', 62 | 'hs_timestamp', 63 | 'hs_email_subject', 64 | 'hs_email_status', 65 | 'hs_email_text', 66 | 'hs_email_direction', 67 | ], 68 | 'include_associations' => ['companies', 'contacts', 'deals', 'tickets'], 69 | ], 70 | 71 | 'notes' => [ 72 | 'include_properties' => ['hubspot_owner_id', 'hs_timestamp', 'hs_note_body'], 73 | 'include_associations' => ['companies', 'contacts', 'deals', 'tickets'], 74 | ], 75 | 76 | 'http' => [ 77 | 'timeout' => env('HUBSPOT_HTTP_TIMEOUT', 10), 78 | 'connect_timeout' => env('HUBSPOT_HTTP_CONNECT_TIMEOUT', 10), 79 | ], 80 | ]; 81 | -------------------------------------------------------------------------------- /src/Api/Client.php: -------------------------------------------------------------------------------- 1 | baseUrl . $uri; 30 | } 31 | 32 | public function http(): PendingRequest 33 | { 34 | return Http::timeout(config('hubspot.http.timeout', 10)) 35 | ->connectTimeout(config('hubspot.http.connect_timeout', 10)) 36 | ->withToken($this->accessToken) 37 | ->throw(function (Response $response, RequestException $exception) { 38 | if ($response->json('category') === 'RATE_LIMITS') { 39 | throw new RateLimitException($response, $exception); 40 | } 41 | 42 | match($response->status()) { 43 | 400 => throw new InvalidRequestException($response->json('message'), 400), 44 | 404 => throw new NotFoundException($response, $exception), 45 | 409 => throw new InvalidRequestException($response->json('message'), 409), 46 | default => dd($response->status(), $response->json()) 47 | }; 48 | }); 49 | } 50 | 51 | public function get(string $uri, $query = []): Response 52 | { 53 | return $this->http()->get( 54 | $this->prefix($uri), 55 | array_filter($query) 56 | ); 57 | } 58 | 59 | public function post(string $uri, array $data = []): Response 60 | { 61 | return $this->http()->post( 62 | $this->prefix($uri), 63 | array_filter($data) 64 | ); 65 | } 66 | 67 | public function patch(string $uri, array $data = []): Response 68 | { 69 | return $this->http()->patch( 70 | $this->prefix($uri), 71 | $data 72 | ); 73 | } 74 | 75 | public function put(string $uri, array $data = []): Response 76 | { 77 | return $this->http()->put( 78 | $this->prefix($uri), 79 | $data 80 | ); 81 | } 82 | 83 | public function delete(string $uri, array $data = []): Response 84 | { 85 | return $this->http()->delete( 86 | $this->prefix($uri), 87 | $data 88 | ); 89 | } 90 | 91 | public function __call($method, $parameters) 92 | { 93 | return $this->forwardCallTo($this->http(), $method, $parameters); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /tests/Unit/Api/AssociationTest.php: -------------------------------------------------------------------------------- 1 | builder = $this->createMock(ApiBuilder::class); 10 | $this->sourceModel = $this->createMock(AbstractApiModel::class); 11 | $this->targetModel = $this->createMock(AbstractApiModel::class); 12 | $this->association = new ApiAssociation(source: $this->sourceModel, target: $this->targetModel); 13 | }); 14 | 15 | test('sourceBuilder returns builder from sourceModel', function () { 16 | $this->sourceModel->expects($this->once())->method('builder')->willReturn($this->builder); 17 | $this->targetModel->expects($this->never())->method('builder'); 18 | $this->association->sourceBuilder(); 19 | }); 20 | 21 | test('builder returns builder from targetModel', function () { 22 | $this->sourceModel->expects($this->never())->method('builder'); 23 | $this->targetModel->expects($this->once())->method('builder')->willReturn($this->builder); 24 | $this->association->builder(); 25 | }); 26 | 27 | test('get doesnt call load when collection set', function () { 28 | $testCollection = new Collection(['test' => $this->getName()]); 29 | $this->targetModel->expects($this->never())->method('builder'); 30 | (new ReflectionProperty($this->association, 'collection'))->setValue($this->association, $testCollection); 31 | $this->assertSame($testCollection, $this->association->get()); 32 | }); 33 | 34 | test('load calls target builders findMany and returns collection', function () { 35 | $testCollection = new Collection(['test' => $this->getName()]); 36 | $ids = [123, 345, 567]; 37 | $this->targetModel->method('builder')->willReturn($this->builder); 38 | $this->builder->expects($this->once())->method('findMany')->with($ids)->willReturn($testCollection); 39 | 40 | (new ReflectionProperty($this->association, 'ids'))->setValue($this->association, $ids); 41 | $this->assertSame($testCollection, $this->association->load()); 42 | }); 43 | 44 | test('get calls load when collection isnt set', function () { 45 | $ids = [123, 345, 567]; 46 | $testCollection = new Collection(['test' => $this->getName()]); 47 | (new ReflectionProperty($this->association, 'ids'))->setValue($this->association, $ids); 48 | $this->targetModel->method('builder')->willReturn($this->builder); 49 | $this->builder->expects($this->once())->method('findMany')->with($ids)->willReturn($testCollection); 50 | $this->assertSame($testCollection, $this->association->get()); 51 | }); 52 | 53 | test('attach gets id from model when targetId is model', function () { 54 | $testId = random_int(100, 999); 55 | $anotherModel = $this->createMock(AbstractApiModel::class); 56 | $anotherModel->expects($this->once())->method('__get')->with('id')->willReturn($testId); 57 | $this->sourceModel->expects($this->once())->method('builder')->willReturn($this->builder); 58 | $this->builder->expects($this->once())->method('associate')->with($this->targetModel, $testId); 59 | 60 | $this->association->attach($anotherModel); 61 | }); 62 | 63 | test('attach uses passed targetId', function () { 64 | $testId = random_int(100, 999); 65 | $this->sourceModel->expects($this->once())->method('builder')->willReturn($this->builder); 66 | $this->builder->expects($this->once())->method('associate')->with($this->targetModel, $testId); 67 | 68 | $this->association->attach($testId); 69 | }); 70 | -------------------------------------------------------------------------------- /src/Api/Model.php: -------------------------------------------------------------------------------- 1 | 'int', 34 | 'properties' => 'array', 35 | 'propertiesWithHistory' => 'array', 36 | 'associations' => 'array', 37 | 'createdAt' => 'datetime', 38 | 'updatedAt' => 'datetime', 39 | 'archived' => 'bool', 40 | 'archivedAt' => 'datetime', 41 | ]; 42 | 43 | protected array $endpoints = [ 44 | "create" => "/v3/objects/{type}", 45 | "read" => "/v3/objects/{type}/{id}", 46 | "batchRead" => "/v3/objects/{type}/batch/read", 47 | "update" => "/v3/objects/{type}/{id}", 48 | "delete" => "/v3/objects/{type}/{id}", 49 | "search" => "/v3/objects/{type}/search", 50 | "associate" => "/v3/objects/{type}/{id}/associations/{association}/{associationId}/{associationType}", 51 | "associations" => "/v3/objects/{type}/{id}/associations/{association}", 52 | "properties" => "/v3/properties/{type}", 53 | ]; 54 | 55 | protected array $allowedproperties = [ 56 | 'email', 57 | 'company' 58 | ]; 59 | 60 | public function __construct(array $properties = []) 61 | { 62 | if (empty($properties)) { 63 | return; 64 | } 65 | $this->fill($properties); 66 | } 67 | 68 | public static function hydrate(array $payload = []): self 69 | { 70 | $instance = new static; 71 | $instance->init($payload); 72 | 73 | return $instance; 74 | } 75 | 76 | protected function init(array $payload = []): static 77 | { 78 | $this->payload = $payload; 79 | $this->fill($payload['properties']); 80 | $this->exists = true; 81 | 82 | return $this; 83 | } 84 | 85 | public function fill(array $properties): static 86 | { 87 | if (empty($properties)) { 88 | return $this; 89 | } 90 | 91 | $properties = array_filter( 92 | $properties, 93 | fn (string $key): bool => $this->isAllowedProperty($key), 94 | ARRAY_FILTER_USE_KEY 95 | ); 96 | 97 | $this->properties = array_merge($this->properties, $properties); 98 | 99 | return $this; 100 | } 101 | 102 | private function isAllowedProperty(string $key): bool 103 | { 104 | return in_array($key, $this->allowedproperties) || 105 | !(HubSpot::isType($key) || HubSpot::isType(Str::plural($key))); 106 | } 107 | 108 | public function type(): string 109 | { 110 | return $this->type; 111 | } 112 | 113 | public function endpoint($key, $fill = []): string 114 | { 115 | $fill['type'] = $this->type; 116 | 117 | if (Arr::has($this->payload, 'id')) { 118 | $fill['id'] = $this->getFromPayload('id'); 119 | } 120 | 121 | return str_replace( 122 | array_map(fn($key) => "{" . $key . "}", array_keys($fill)), 123 | array_values($fill), 124 | $this->endpoints[$key] 125 | ); 126 | } 127 | 128 | public function expand(): static 129 | { 130 | return $this->init( 131 | $this->builder()->full()->item($this->id) 132 | ); 133 | } 134 | 135 | public static function create(array $properties = []): static 136 | { 137 | return static::hydrate( 138 | static::query()->create($properties) 139 | ); 140 | } 141 | 142 | public function update(array $properties = []): static 143 | { 144 | return $this->fill($properties)->save(); 145 | } 146 | 147 | public function delete(): bool 148 | { 149 | $this->builder()->delete(); 150 | $this->exists = false; 151 | 152 | return true; 153 | } 154 | 155 | public function save(): static 156 | { 157 | return $this->exists 158 | ? $this->fill($this->builder()->update($this->getDirty())['properties']) 159 | : $this->init($this->builder()->create($this->properties)); 160 | } 161 | 162 | public function builder(): Builder 163 | { 164 | return app(Builder::class)->for($this); 165 | } 166 | 167 | public static function query(): Builder 168 | { 169 | return (new static())->builder(); 170 | } 171 | 172 | public function getFromPayload($key, $default = null): mixed 173 | { 174 | return $this->cast( 175 | Arr::get($this->payload, $key, $default), 176 | Arr::get($this->schema, $key) 177 | ); 178 | } 179 | 180 | public function getFromProperties($key): mixed 181 | { 182 | $value = Arr::get($this->properties, $key); 183 | 184 | return !is_a($this, Owner::class) && !is_a($this, Property::class) && $this->definitions->has($key) 185 | ? $this->definitions->get($key)->unserialize($value) 186 | : $value; 187 | } 188 | 189 | public function getDirty(): array 190 | { 191 | $dirty = []; 192 | $original = Arr::get($this->payload, 'properties', []); 193 | 194 | foreach ($this->properties as $key => $value) { 195 | if (! array_key_exists($key, $original) || $original[$key] !== $value) { 196 | $dirty[$key] = $value; 197 | } 198 | } 199 | 200 | return $dirty; 201 | } 202 | 203 | protected function cast($value, $type = null): mixed 204 | { 205 | return match ($type) { 206 | 'int' => (int)$value, 207 | 'datetime' => Carbon::parse($value), 208 | 'array' => (array)$value, 209 | 'string' => (string)$value, 210 | default => $value 211 | }; 212 | } 213 | 214 | public function hasNamedScope($scope): bool 215 | { 216 | return method_exists($this, 'scope'.ucfirst($scope)); 217 | } 218 | 219 | public function callNamedScope($scope, array $parameters = []) 220 | { 221 | return $this->{'scope'.ucfirst($scope)}(...$parameters); 222 | } 223 | 224 | public function only($attributes): array 225 | { 226 | $results = []; 227 | 228 | foreach (is_array($attributes) ? $attributes : func_get_args() as $attribute) { 229 | $results[$attribute] = $this->getFromProperties($attribute); 230 | } 231 | 232 | return $results; 233 | } 234 | 235 | public function toArray(): array 236 | { 237 | return $this->payload; 238 | } 239 | 240 | public function __get($key) 241 | { 242 | if ($key === "definitions") { 243 | return $this->builder()->definitions()->get(); 244 | } 245 | 246 | if (array_key_exists($key, $this->properties)) { 247 | return $this->getFromProperties($key); 248 | } 249 | 250 | if (array_key_exists($key, $this->payload)) { 251 | return $this->getFromPayload($key); 252 | } 253 | 254 | if (HubSpot::isType($key)) { 255 | return $this->getAssociations($key); 256 | } 257 | 258 | if (HubSpot::isType(Str::plural($key))) { 259 | return $this->getAssociations(Str::plural($key))->first(); 260 | } 261 | 262 | return null; 263 | } 264 | 265 | public function __set($key, $value) 266 | { 267 | if ($this->isAllowedProperty($key)) { 268 | $this->properties[$key] = $value; 269 | } 270 | } 271 | 272 | public function __isset($key) 273 | { 274 | return isset($this->properties[$key]); 275 | } 276 | 277 | public static function __callStatic($method, $parameters) 278 | { 279 | return (new static)->$method(...$parameters); 280 | } 281 | 282 | public function __call($method, $parameters) 283 | { 284 | if (HubSpot::isType($method)) { 285 | return $this->associations($method); 286 | } 287 | 288 | return $this->forwardCallTo($this->builder(), $method, $parameters); 289 | } 290 | } 291 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HubSpot CRM SDK for Laravel 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/stechstudio/laravel-hubspot.svg?style=flat-square)](https://packagist.org/packages/stechstudio/laravel-hubspot) 4 | 5 | [//]: # ([![Total Downloads](https://img.shields.io/packagist/dt/stechstudio/laravel-hubspot.svg?style=flat-square)](https://packagist.org/packages/stechstudio/laravel-hubspot)) 6 | 7 | Interact with HubSpot's CRM with an enjoyable, Eloquent-like developer experience. 8 | 9 | - Familiar Eloquent CRUD methods `create`, `find`, `update`, and `delete` 10 | - Associated objects work like relations: `Deal::find(555)->notes` and `Contact::find(789)->notes()->create(...)` 11 | - Retrieving lists of objects feels like a query builder: `Company::where('state','NC')->orderBy('custom_property')->paginate(20)` 12 | - Cursors provide a seamless way to loop through all records: `foreach(Contact::cursor() AS $contact) { ... }` 13 | 14 | > **Note** 15 | > Only the CRM API is currently implemented. 16 | 17 | ## Installation 18 | 19 | ### 1) Install the package via composer: 20 | 21 | ```bash 22 | composer require stechstudio/laravel-hubspot 23 | ``` 24 | 25 | ### 2) Configure HubSpot 26 | 27 | [Create a private HubSpot app](https://developers.hubspot.com/docs/api/private-apps#create-a-private-app) and give it appropriate scopes for what you want to do with this SDK. 28 | 29 | Copy the provided access token, and add to your Laravel `.env` file: 30 | 31 | ```bash 32 | HUBSPOT_ACCESS_TOKEN=XXX-XXX-XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX 33 | ``` 34 | 35 | ## Usage 36 | 37 | ### Individual CRUD actions 38 | 39 | #### Retrieving 40 | 41 | You may retrieve single object records using the `find` method on any object class and providing the ID. 42 | 43 | ```php 44 | use STS\HubSpot\Crm\Contact; 45 | 46 | $contact = Contact::find(123); 47 | ``` 48 | 49 | #### Creating 50 | 51 | To create a new object record, use the `create` method and provide an array of properties. 52 | 53 | ```php 54 | $contact = Contact::create([ 55 | 'firstname' => 'Test', 56 | 'lastname' => 'User', 57 | 'email' => 'testuser@example.com' 58 | ]); 59 | ``` 60 | 61 | Alternatively you can create the class first, provide properties one at a time, and then `save`. 62 | 63 | ```php 64 | $contact = new Contact; 65 | $contact->firstname = 'Test'; 66 | $contact->lastname = 'User'; 67 | $contact->email = 'testuser@example.com'; 68 | $contact->save(); 69 | ``` 70 | 71 | #### Updating 72 | 73 | Once you have retrieved or created an object, update with the `update` method and provide an array of properties to change. 74 | 75 | ```php 76 | Contact::find(123)->update([ 77 | 'email' => 'newemail@example.com' 78 | ]); 79 | ``` 80 | 81 | You can also change properties individually and then `save`. 82 | 83 | ```php 84 | $contact = Contact::find(123); 85 | $contact->email = 'newemail@example.com'; 86 | $contact->save(); 87 | ``` 88 | 89 | #### Deleting 90 | 91 | This will "archive" the object in HubSpot. 92 | 93 | ```php 94 | Contact::find(123)->delete(); 95 | ``` 96 | 97 | ### Retrieving multiple objects 98 | 99 | Fetching a collection of objects from an API is different from querying a database directly in that you will always be limited in how many items you can fetch at once. You can't ask HubSpot to return ALL your contacts if you have thousands. 100 | 101 | This package provides three different ways of fetching these results. 102 | 103 | #### Paginating 104 | 105 | Similar to a traditional database paginated result, you can paginate through a HubSpot collection of objects. 106 | You will receive a `LengthAwarePaginator` just like with Eloquent, which means you can generate links in your UI just like you are used to. 107 | 108 | ```php 109 | $contacts = Contact::paginate(20); 110 | ``` 111 | 112 | By default, this `paginate` method will look at the `page` query parameter. You can customize the query parameter key by passing a string as the second argument. 113 | 114 | #### Cursor iteration 115 | 116 | You can use the `cursor` method to iterate over the entire collection of objects. 117 | This uses [lazy collections](https://laravel.com/docs/9.x/collections#lazy-collections) and [generators](https://www.php.net/manual/en/language.generators.overview.php) 118 | to seamlessly fetch chunks of records from the API as needed, hydrating objects when needed, and providing smooth iteration over a limitless number of objects. 119 | 120 | ```php 121 | // This will iterate over ALL your contacts! 122 | foreach(Contact::cursor() AS $contact) { 123 | echo $contact->id . "
"; 124 | } 125 | ``` 126 | 127 | > **Warning** 128 | > API rate limiting can be an obstacle when using this approach. Be careful about iterating over huge datasets very quickly, as this will still require quite a few API calls in the background. 129 | 130 | #### Manually fetching chunks 131 | 132 | Of course, you can grab collections of records with your own manual pagination or chunking logic. 133 | Use the `take` and `after` methods to specify what you want to grab, and then `get`. 134 | 135 | ```php 136 | // This will get 100 contact records, starting at 501 137 | $contacts = Contact::take(100)->after(500)->get(); 138 | 139 | // This will get the default 50 records, starting at the first one 140 | $contacts = Contact::get(); 141 | ``` 142 | 143 | ### Searching and filtering 144 | 145 | When retrieving multiple objects, you will frequently want to filter, search, and order these results. 146 | You can use a fluent interface to build up a query before retrieving the results. 147 | 148 | #### Adding filters 149 | 150 | Use the `where` method to add filters to your query. 151 | You can use any of the supported operators for the second argument, see here for the full list: [https://developers.hubspot.com/docs/api/crm/search#filter-search-results](https://developers.hubspot.com/docs/api/crm/search#filter-search-results); 152 | 153 | This package also provides friendly aliases for common operators `=`, `!=`, `>`, `>=`, `<`, `<=`, `exists`, `not exists`, `like`, and `not like`. 154 | 155 | ```php 156 | Contact::where('lastname','!=','Smith')->get(); 157 | ``` 158 | 159 | You can omit the operator argument and `=` will be used. 160 | 161 | ```php 162 | Contact::where('email', 'johndoe@example.com')->get(); 163 | ``` 164 | 165 | For the `BETWEEN` operator, provide the lower and upper bounds as a two-element tuple. 166 | 167 | ```php 168 | Contact::where('days_to_close', 'BETWEEN', [30, 60])->get(); 169 | ``` 170 | 171 | > **Note** 172 | > All filters added are grouped as "AND" filters, and applied together. Optional "OR" grouping is not yet supported. 173 | 174 | #### Searching common properties 175 | 176 | HubSpot supports searching through certain object properties very easily. See here for details: 177 | 178 | [https://developers.hubspot.com/docs/api/crm/search#search-default-searchable-properties](https://developers.hubspot.com/docs/api/crm/search#search-default-searchable-properties) 179 | 180 | Specify a search parameter with the `search` method: 181 | 182 | ```php 183 | Contact::search('1234')->get(); 184 | ``` 185 | 186 | #### Ordering 187 | 188 | You can order the results with any property. 189 | 190 | ```php 191 | Contact::orderBy('lastname')->get(); 192 | ``` 193 | 194 | The default direction is `asc`, you can change this to `desc` if needed. 195 | 196 | ```php 197 | Contact::orderBy('days_to_close', 'desc')->get(); 198 | ``` 199 | 200 | ### Associations 201 | 202 | HubSpot associations are handled similar to Eloquent relationships. 203 | 204 | #### Dynamic properties 205 | 206 | You can access associated objects using dynamic properties. 207 | 208 | ```php 209 | foreach(Company::find(555)->contacts AS $contact) { 210 | echo $contact->email; 211 | } 212 | ``` 213 | 214 | #### Association methods 215 | 216 | If you need to add additional constraints, use the association method. You can add any of the filtering, searching, or ordering methods described above. 217 | 218 | ```php 219 | Company::find(555)->contacts() 220 | ->where('days_to_close', 'BETWEEN', [30, 60]) 221 | ->search('smith') 222 | ->get(); 223 | ``` 224 | 225 | #### Eager loading association IDs 226 | 227 | Normally, there are three HubSpot API calls to achieve the above result: 228 | 229 | 1. Fetch the company object 230 | 2. Retrieve all the contact IDs that are associated to this company 231 | 3. Query for contacts that match the IDs 232 | 233 | Now we can eliminate the second API call by eager loading the associated contact IDs. 234 | This library always eager-loads the IDs for associated companies, contacts, deals, and tickets. It does not eager-load 235 | IDs for engagements like emails and notes, since those association will tend to be much longer lists. 236 | 237 | If you know in advance that you want to, say, retrieve the notes for a contact, you can specify this up front. 238 | 239 | ```php 240 | // This will only be two API calls, not three 241 | Contact::with('notes')->find(123)->notes; 242 | ``` 243 | 244 | #### Creating associated objects 245 | 246 | You can create new records off of the association methods. 247 | 248 | ```php 249 | Company::find(555)->contacts()->create([ 250 | 'firstname' => 'Test', 251 | 'lastname' => 'User', 252 | 'email' => 'testuser@example.com' 253 | ]); 254 | ``` 255 | 256 | This will create a new contact, associate it to the company, and return the new contact. 257 | 258 | You can also associate existing objects using `attach`. This method accepts and ID or an object instance. 259 | 260 | ```php 261 | Company::find(555)->contacts()->attach(Contact::find(123)); 262 | ``` 263 | 264 | You can also detach existing objects using `detach`. This method accepts and ID or an object instance. 265 | 266 | ```php 267 | Company::find(555)->contacts()->detach(Contact::find(123)); 268 | ``` 269 | 270 | 271 | 272 | ## License 273 | 274 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 275 | -------------------------------------------------------------------------------- /configure.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | $version) { 90 | if (in_array($name, $names, true)) { 91 | unset($data['require-dev'][$name]); 92 | } 93 | } 94 | 95 | file_put_contents(__DIR__.'/composer.json', json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); 96 | } 97 | 98 | function remove_composer_script($scriptName) 99 | { 100 | $data = json_decode(file_get_contents(__DIR__.'/composer.json'), true); 101 | 102 | foreach ($data['scripts'] as $name => $script) { 103 | if ($scriptName === $name) { 104 | unset($data['scripts'][$name]); 105 | break; 106 | } 107 | } 108 | 109 | file_put_contents(__DIR__.'/composer.json', json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); 110 | } 111 | 112 | function remove_readme_paragraphs(string $file): void 113 | { 114 | $contents = file_get_contents($file); 115 | 116 | file_put_contents( 117 | $file, 118 | preg_replace('/.*/s', '', $contents) ?: $contents 119 | ); 120 | } 121 | 122 | function safeUnlink(string $filename) 123 | { 124 | if (file_exists($filename) && is_file($filename)) { 125 | unlink($filename); 126 | } 127 | } 128 | 129 | function determineSeparator(string $path): string 130 | { 131 | return str_replace('/', DIRECTORY_SEPARATOR, $path); 132 | } 133 | 134 | function replaceForWindows(): array 135 | { 136 | return preg_split('/\\r\\n|\\r|\\n/', run('dir /S /B * | findstr /v /i .git\ | findstr /v /i vendor | findstr /v /i '.basename(__FILE__).' | findstr /r /i /M /F:/ ":author :vendor :package VendorName skeleton migration_table_name vendor_name vendor_slug author@domain.com"')); 137 | } 138 | 139 | function replaceForAllOtherOSes(): array 140 | { 141 | return explode(PHP_EOL, run('grep -E -r -l -i ":author|:vendor|:package|VendorName|skeleton|migration_table_name|vendor_name|vendor_slug|author@domain.com" --exclude-dir=vendor ./* ./.github/* | grep -v '.basename(__FILE__))); 142 | } 143 | 144 | $gitName = run('git config user.name'); 145 | $authorName = ask('Author name', $gitName); 146 | 147 | $gitEmail = run('git config user.email'); 148 | $authorEmail = ask('Author email', $gitEmail); 149 | 150 | $usernameGuess = explode(':', run('git config remote.origin.url'))[1]; 151 | $usernameGuess = dirname($usernameGuess); 152 | $usernameGuess = basename($usernameGuess); 153 | $authorUsername = ask('Author username', $usernameGuess); 154 | 155 | $vendorName = ask('Vendor name', $authorUsername); 156 | $vendorSlug = slugify($vendorName); 157 | $vendorNamespace = ucwords($vendorName); 158 | $vendorNamespace = ask('Vendor namespace', $vendorNamespace); 159 | 160 | $currentDirectory = getcwd(); 161 | $folderName = basename($currentDirectory); 162 | 163 | $packageName = ask('Package name', $folderName); 164 | $packageSlug = slugify($packageName); 165 | $packageSlugWithoutPrefix = remove_prefix('laravel-', $packageSlug); 166 | 167 | $className = title_case($packageName); 168 | $className = ask('Class name', $className); 169 | $variableName = lcfirst($className); 170 | $description = ask('Package description', "This is my package {$packageSlug}"); 171 | 172 | $usePhpStan = confirm('Enable PhpStan?', true); 173 | $useLaravelPint = confirm('Enable Laravel Pint?', true); 174 | $useDependabot = confirm('Enable Dependabot?', true); 175 | $useLaravelRay = confirm('Use Ray for debugging?', true); 176 | $useUpdateChangelogWorkflow = confirm('Use automatic changelog updater workflow?', true); 177 | 178 | writeln('------'); 179 | writeln("Author : {$authorName} ({$authorUsername}, {$authorEmail})"); 180 | writeln("Vendor : {$vendorName} ({$vendorSlug})"); 181 | writeln("Package : {$packageSlug} <{$description}>"); 182 | writeln("Namespace : {$vendorNamespace}\\{$className}"); 183 | writeln("Class name : {$className}"); 184 | writeln('---'); 185 | writeln('Packages & Utilities'); 186 | writeln('Use Laravel/Pint : '.($useLaravelPint ? 'yes' : 'no')); 187 | writeln('Use Larastan/PhpStan : '.($usePhpStan ? 'yes' : 'no')); 188 | writeln('Use Dependabot : '.($useDependabot ? 'yes' : 'no')); 189 | writeln('Use Ray App : '.($useLaravelRay ? 'yes' : 'no')); 190 | writeln('Use Auto-Changelog : '.($useUpdateChangelogWorkflow ? 'yes' : 'no')); 191 | writeln('------'); 192 | 193 | writeln('This script will replace the above values in all relevant files in the project directory.'); 194 | 195 | if (! confirm('Modify files?', true)) { 196 | exit(1); 197 | } 198 | 199 | $files = (str_starts_with(strtoupper(PHP_OS), 'WIN') ? replaceForWindows() : replaceForAllOtherOSes()); 200 | 201 | foreach ($files as $file) { 202 | replace_in_file($file, [ 203 | ':author_name' => $authorName, 204 | ':author_username' => $authorUsername, 205 | 'author@domain.com' => $authorEmail, 206 | ':vendor_name' => $vendorName, 207 | ':vendor_slug' => $vendorSlug, 208 | 'VendorName' => $vendorNamespace, 209 | ':package_name' => $packageName, 210 | ':package_slug' => $packageSlug, 211 | ':package_slug_without_prefix' => $packageSlugWithoutPrefix, 212 | 'Skeleton' => $className, 213 | 'skeleton' => $packageSlug, 214 | 'migration_table_name' => title_snake($packageSlug), 215 | 'variable' => $variableName, 216 | ':package_description' => $description, 217 | ]); 218 | 219 | match (true) { 220 | str_contains($file, determineSeparator('src/HubSpot.php')) => rename($file, determineSeparator('./src/'.$className.'.php')), 221 | str_contains($file, determineSeparator('src/SkeletonServiceProvider.php')) => rename($file, determineSeparator('./src/'.$className.'ServiceProvider.php')), 222 | str_contains($file, determineSeparator('src/Facades/HubSpot.php')) => rename($file, determineSeparator('./src/Facades/'.$className.'.php')), 223 | str_contains($file, determineSeparator('src/Commands/SkeletonCommand.php')) => rename($file, determineSeparator('./src/Commands/'.$className.'Command.php')), 224 | str_contains($file, determineSeparator('database/migrations/create_skeleton_table.php.stub')) => rename($file, determineSeparator('./database/migrations/create_'.title_snake($packageSlugWithoutPrefix).'_table.php.stub')), 225 | str_contains($file, determineSeparator('config/hubspot.php')) => rename($file, determineSeparator('./config/'.$packageSlugWithoutPrefix.'.php')), 226 | str_contains($file, 'README.md') => remove_readme_paragraphs($file), 227 | default => [], 228 | }; 229 | } 230 | 231 | if (! $useLaravelPint) { 232 | safeUnlink(__DIR__.'/.github/workflows/fix-php-code-style-issues.yml'); 233 | safeUnlink(__DIR__.'/pint.json'); 234 | } 235 | 236 | if (! $usePhpStan) { 237 | safeUnlink(__DIR__.'/phpstan.neon.dist'); 238 | safeUnlink(__DIR__.'/phpstan-baseline.neon'); 239 | safeUnlink(__DIR__.'/.github/workflows/phpstan.yml'); 240 | 241 | remove_composer_deps([ 242 | 'phpstan/extension-installer', 243 | 'phpstan/phpstan-deprecation-rules', 244 | 'phpstan/phpstan-phpunit', 245 | 'nunomaduro/larastan', 246 | ]); 247 | 248 | remove_composer_script('phpstan'); 249 | } 250 | 251 | if (! $useDependabot) { 252 | safeUnlink(__DIR__.'/.github/dependabot.yml'); 253 | safeUnlink(__DIR__.'/.github/workflows/dependabot-auto-merge.yml'); 254 | } 255 | 256 | if (! $useLaravelRay) { 257 | remove_composer_deps(['spatie/laravel-ray']); 258 | } 259 | 260 | if (! $useUpdateChangelogWorkflow) { 261 | safeUnlink(__DIR__.'/.github/workflows/update-changelog.yml'); 262 | } 263 | 264 | confirm('Execute `composer install` and run tests?') && run('composer install && composer test'); 265 | 266 | confirm('Let this script delete itself?', true) && unlink(__FILE__); 267 | -------------------------------------------------------------------------------- /src/Api/Builder.php: -------------------------------------------------------------------------------- 1 | object = $object; 48 | $this->objectClass = get_class($object); 49 | 50 | $this->defaultProperties = config("hubspot.{$this->object->type()}.include_properties", []); 51 | $this->defaultAssociations = config("hubspot.{$this->object->type()}.include_associations", []); 52 | 53 | return $this; 54 | } 55 | 56 | public function include($properties): static 57 | { 58 | $this->properties = is_array($properties) 59 | ? $properties 60 | : func_get_args(); 61 | 62 | return $this; 63 | } 64 | 65 | public function includeOnly($properties): static 66 | { 67 | return $this->clearProperties()->include(...func_get_args()); 68 | } 69 | 70 | /** 71 | * Alias of the above includeOnly() method 72 | */ 73 | public function select($properties): static 74 | { 75 | return $this->includeOnly(...func_get_args()); 76 | } 77 | 78 | public function clearProperties(): static 79 | { 80 | $this->defaultProperties = []; 81 | 82 | return $this; 83 | } 84 | 85 | public function full(): static 86 | { 87 | return $this->include( 88 | $this->definitions()->get()->keys()->all() 89 | ); 90 | } 91 | 92 | public function with($associations): static 93 | { 94 | $this->associations = is_array($associations) 95 | ? $associations 96 | : func_get_args(); 97 | 98 | return $this; 99 | } 100 | 101 | public function withOnly($associations): static 102 | { 103 | return $this->clearAssociations()->with(...func_get_args()); 104 | } 105 | 106 | public function clearAssociations(): static 107 | { 108 | $this->defaultAssociations = []; 109 | 110 | return $this; 111 | } 112 | 113 | public function create(array $properties): array 114 | { 115 | return $this->client()->post( 116 | $this->object->endpoint('create'), 117 | ['properties' => $properties] 118 | )->json(); 119 | } 120 | 121 | public function item($id, $idProperty = null): array 122 | { 123 | return $this->client()->get( 124 | $this->object->endpoint('read', ['id' => $id]), 125 | [ 126 | 'properties' => implode(",", $this->includeProperties()), 127 | 'associations' => implode(",", $this->includeAssociations()), 128 | 'idProperty' => $idProperty 129 | ] 130 | )->json(); 131 | } 132 | 133 | public function find($id, $idProperty = null): Model|Collection|null 134 | { 135 | if (is_array($id) || $id instanceof Arrayable) { 136 | return $this->findMany($id, $idProperty); 137 | } 138 | 139 | try { 140 | return $this->findOrFail($id, $idProperty); 141 | } catch (NotFoundException $e) { 142 | return null; 143 | } 144 | } 145 | 146 | public function findMany(array|Arrayable $ids, $idProperty = null): Collection 147 | { 148 | if ($ids instanceof Arrayable) { 149 | $ids = $ids->toArray(); 150 | } 151 | $ids = array_filter(array_unique($ids)); 152 | 153 | if (count($ids) === 1) { 154 | return new Collection([$this->find($ids[0], $idProperty)]); 155 | } 156 | 157 | if (!count($ids)) { 158 | return new Collection(); 159 | } 160 | 161 | $response = $this->client()->post( 162 | $this->object->endpoint('batchRead'), 163 | [ 164 | 'properties' => $this->includeProperties(), 165 | 'idProperty' => $idProperty, 166 | 'inputs' => array_map(fn($id) => ['id' => $id], $ids) 167 | ] 168 | )->json(); 169 | 170 | return Collection::hydrate($response, $this->objectClass); 171 | } 172 | 173 | public function findOrFail($id, $idProperty = null): Model 174 | { 175 | return ($this->hydrateObject( 176 | $this->item($id, $idProperty) 177 | ))->has($this->includeAssociations()); 178 | } 179 | 180 | public function findOrNew($id, $idProperty = null) 181 | { 182 | if (!is_null($model = $this->find($id, $idProperty))) { 183 | return $model; 184 | } 185 | 186 | return new $this->objectClass(); 187 | } 188 | 189 | public function findOr($id, $idProperty = null, Closure $callback = null) 190 | { 191 | if ($idProperty instanceof Closure) { 192 | $callback = $idProperty; 193 | 194 | $idProperty = null; 195 | } 196 | 197 | if (!is_null($model = $this->find($id, $idProperty))) { 198 | return $model; 199 | } 200 | 201 | return $callback(); 202 | } 203 | 204 | public function update(array $properties): array 205 | { 206 | return $this->client()->patch( 207 | $this->object->endpoint('update'), 208 | ['properties' => $properties] 209 | )->json(); 210 | } 211 | 212 | public function delete(): bool 213 | { 214 | $this->client()->delete( 215 | $this->object->endpoint('delete') 216 | ); 217 | 218 | return true; 219 | } 220 | 221 | public function where($property, $condition, $value = null): static 222 | { 223 | $this->filters[] = new Filter($property, $condition, $value); 224 | 225 | return $this; 226 | } 227 | 228 | public function whereKey($ids, $idProperty = null): Collection 229 | { 230 | return $this->findMany(Arr::wrap($ids), $idProperty); 231 | } 232 | 233 | public function orderBy($property, $direction = 'ASC'): static 234 | { 235 | $this->sort = [ 236 | 'propertyName' => $property, 237 | 'direction' => strtoupper($direction) === 'DESC' ? 'DESCENDING' : 'ASCENDING' 238 | ]; 239 | 240 | return $this; 241 | } 242 | 243 | public function take(int $limit): static 244 | { 245 | $this->limit = $limit; 246 | 247 | return $this; 248 | } 249 | 250 | public function skip(int $after): static 251 | { 252 | $this->after = $after; 253 | 254 | return $this; 255 | } 256 | 257 | public function search($input): static 258 | { 259 | $this->query = $input; 260 | 261 | return $this; 262 | } 263 | 264 | public function items($after = null, $limit = null): array 265 | { 266 | return $this->client()->post( 267 | $this->object->endpoint('search'), 268 | [ 269 | 'limit' => $limit ?? $this->limit, 270 | 'after' => $after ?? $this->after ?? null, 271 | 'query' => $this->query ?? null, 272 | 'properties' => $this->includeProperties(), 273 | 'sorts' => isset($this->sort) ? [$this->sort] : null, 274 | 'filterGroups' => [[ 275 | 'filters' => array_map(fn($filter) => $filter->toArray(), $this->filters) 276 | ]] 277 | ] 278 | )->json(); 279 | } 280 | 281 | public function get() 282 | { 283 | return Collection::hydrate( 284 | $this->items(), 285 | $this->objectClass 286 | ); 287 | } 288 | 289 | public function cursor(): LazyCollection 290 | { 291 | return new LazyCollection(function () { 292 | $after = 0; 293 | 294 | do { 295 | $response = $this->items($after); 296 | $after = Arr::get($response, 'paging.next.after'); 297 | 298 | foreach ($response['results'] as $payload) { 299 | yield $this->hydrateObject($payload); 300 | } 301 | } while ($after !== null); 302 | }); 303 | } 304 | 305 | public function paginate($perPage = 50, $pageName = 'page', $page = null): LengthAwarePaginator 306 | { 307 | $page = $page ?: Paginator::resolveCurrentPage($pageName); 308 | 309 | $results = Collection::hydrate( 310 | $this->items($perPage * $page, $perPage), 311 | $this->objectClass 312 | ); 313 | 314 | return new LengthAwarePaginator( 315 | $results, $results->total(), $perPage, $page, [ 316 | 'path' => Paginator::resolveCurrentPath(), 317 | 'pageName' => $pageName, 318 | ] 319 | ); 320 | } 321 | 322 | public function count(): int 323 | { 324 | return $this->take(1)->get()->total(); 325 | } 326 | 327 | public function first(): Model|null 328 | { 329 | return $this->take(1)->get()->first(); 330 | } 331 | 332 | public function associate(Model $target, $targetId) 333 | { 334 | return $this->client()->put( 335 | $this->object->endpoint('associate', [ 336 | 'association' => $target->type(), 337 | 'associationId' => $targetId, 338 | 'associationType' => Str::singular($this->object->type()) . "_to_" . Str::singular($target->type()) 339 | ]) 340 | )->json(); 341 | } 342 | 343 | public function deleteAssociation(Model $target, $targetId) 344 | { 345 | return $this->client()->delete( 346 | $this->object->endpoint('associate', [ 347 | 'association' => $target->type(), 348 | 'associationId' => $targetId, 349 | 'associationType' => Str::singular($this->object->type()) . "_to_" . Str::singular($target->type()) 350 | ]) 351 | )->json(); 352 | } 353 | 354 | public function client(): Client 355 | { 356 | return $this->client; 357 | } 358 | 359 | public function associations($association): array 360 | { 361 | return $this->client()->get( 362 | $this->object->endpoint('associations', ['id' => $this->object->id, 'association' => $association]) 363 | )->json()['results']; 364 | } 365 | 366 | public function definitions(): PropertyDefinition 367 | { 368 | return new PropertyDefinition($this->object, $this); 369 | } 370 | 371 | public function properties(): Collection 372 | { 373 | return Collection::hydrate( 374 | $this->client()->get( 375 | $this->object->endpoint('properties') 376 | )->json(), 377 | Property::class 378 | ); 379 | } 380 | 381 | public function createDefinition(array $properties): Property 382 | { 383 | return Property::hydrate( 384 | $this->client()->post( 385 | $this->object->endpoint('properties'), 386 | $properties 387 | )->json() 388 | ); 389 | } 390 | 391 | protected function hydrateObject($payload): Model 392 | { 393 | $class = $this->objectClass; 394 | 395 | return $class::hydrate($payload); 396 | } 397 | 398 | protected function includeProperties(): array 399 | { 400 | return array_merge($this->defaultProperties, $this->properties); 401 | } 402 | 403 | protected function includeAssociations(): array 404 | { 405 | return array_merge($this->defaultAssociations, $this->associations); 406 | } 407 | 408 | public function __call($method, $parameters) 409 | { 410 | if ($this->object->hasNamedScope($method)) { 411 | array_unshift($parameters, $this); 412 | 413 | $response = $this->object->callNamedScope($method, $parameters); 414 | 415 | return $response ?? $this; 416 | } 417 | 418 | throw new BadMethodCallException(sprintf( 419 | 'Call to undefined method %s::%s()', static::class, $method 420 | )); 421 | } 422 | } 423 | -------------------------------------------------------------------------------- /tests/Unit/Api/ModelTest.php: -------------------------------------------------------------------------------- 1 | builder = $this->createMock(ApiBuilder::class); 16 | $this->app->instance(ApiBuilder::class, $this->builder); 17 | }); 18 | 19 | test('new model fills properties on creation', function () { 20 | $testValue = ['test_attribute' => sha1(random_bytes(11))]; 21 | 22 | (new class($testValue) extends AbstractApiModel { 23 | public function fill(array $properties): static 24 | { 25 | Assert::assertArrayHasKey('test_attribute', $properties); 26 | 27 | $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; 28 | Assert::assertSame('__construct', $trace['function']); 29 | Assert::assertSame(AbstractApiModel::class, $trace['class']); 30 | return $this; 31 | } 32 | }); 33 | }); 34 | 35 | test('new model does not call fill when empty params', function () { 36 | new class([]) extends AbstractApiModel { 37 | public function fill(array $properties): static 38 | { 39 | Assert::fail('fill was called from __construct when empty params'); 40 | } 41 | }; 42 | $this->addToAssertionCount(1); 43 | }); 44 | 45 | test('fill does not fill hubspot types except email and company', function (string $type) { 46 | $baseData = ['test_name' => $this->getName()]; 47 | $model = (new class extends AbstractApiModel { 48 | })->fill([$type => sha1(random_bytes(11)), ...$baseData]); 49 | 50 | $properties = new ReflectionProperty($model, 'properties'); 51 | if ($type === 'email') { 52 | expect($properties->getValue($model)) 53 | ->toBeArray() 54 | ->toHaveKey('email') 55 | ->toHaveKey('test_name'); 56 | } elseif ($type === 'company') { 57 | expect($properties->getValue($model)) 58 | ->toBeArray() 59 | ->toHaveKey('company') 60 | ->toHaveKey('test_name'); 61 | } else { 62 | expect($properties->getValue($model))->toBe($baseData); 63 | } 64 | })->with('SdkTypes-both'); 65 | 66 | test('update calls fill & save', function () { 67 | $testValue = ['test_attribute' => sha1(random_bytes(11))]; 68 | 69 | (new class extends AbstractApiModel { 70 | public function fill(array $properties): static 71 | { 72 | Assert::assertArrayHasKey('test_attribute', $properties); 73 | $this->assertBacktraceIsUpdate(); 74 | return $this; 75 | } 76 | 77 | public function save(): static 78 | { 79 | $this->assertBacktraceIsUpdate(); 80 | return $this; 81 | } 82 | 83 | protected function assertBacktraceIsUpdate(): void 84 | { 85 | $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]; 86 | dump($trace); 87 | Assert::assertSame('update', $trace['function']); 88 | Assert::assertSame(AbstractApiModel::class, $trace['class']); 89 | } 90 | 91 | })->update($testValue); 92 | }); 93 | 94 | test('setting value on model sets property key to value', function () { 95 | $model = new class extends AbstractApiModel { 96 | }; 97 | $attributes = new ReflectionProperty($model, 'properties'); 98 | 99 | $propName = sha1(random_bytes(11)); 100 | $propValue = sha1(random_bytes(11)); 101 | 102 | $model->$propName = $propValue; 103 | 104 | expect($attributes->getValue($model)) 105 | ->toBeArray() 106 | ->toHaveCount(1) 107 | ->toHaveKey($propName, $propValue); 108 | }); 109 | 110 | test('setting hubspot types on model does not set value', function (string $propName) { 111 | $model = new class extends AbstractApiModel { 112 | }; 113 | $attributes = new ReflectionProperty($model, 'properties'); 114 | 115 | $propValue = sha1(random_bytes(11)); 116 | 117 | $model->$propName = $propValue; 118 | 119 | if ($propName === 'email') { 120 | expect($attributes->getValue($model)) 121 | ->toBeArray() 122 | ->toHaveKey('email', $propValue); 123 | } elseif ($propName === 'company') { 124 | expect($attributes->getValue($model)) 125 | ->toBeArray() 126 | ->toHaveKey('company', $propValue); 127 | } else { 128 | expect($attributes->getValue($model)) 129 | ->toBeArray() 130 | ->toBeEmpty(); 131 | } 132 | })->with('SdkTypes-both'); 133 | 134 | test('builder returns api builder', function () { 135 | $model = new class extends AbstractApiModel { 136 | }; 137 | 138 | $this->builder 139 | ->expects($this->once()) 140 | ->method('for') 141 | ->with($model) 142 | ->willReturnSelf(); 143 | 144 | expect($model->builder()) 145 | ->toBe($this->builder); 146 | }); 147 | 148 | test('static query returns api builder', function () { 149 | $model = new class extends AbstractApiModel { 150 | }; 151 | 152 | $this->builder 153 | ->expects($this->once()) 154 | ->method('for') 155 | ->with($this->callback( 156 | function ($param) { 157 | $this->assertInstanceOf(AbstractApiModel::class, $param); 158 | return true; 159 | } 160 | )) 161 | ->willReturnSelf(); 162 | 163 | expect($model::query()) 164 | ->toBe($this->builder); 165 | }); 166 | 167 | test('magic get value on model calls getFromProperties when property set', function () { 168 | $propName = sha1(random_bytes(11)); 169 | $propValue = sha1(random_bytes(11)); 170 | 171 | $model = (new class extends AbstractApiModel { 172 | protected string $type = 'deals'; 173 | private string $expectedKey; 174 | 175 | public function setExpectedKey(string $expectedKey) 176 | { 177 | $this->expectedKey = $expectedKey; 178 | return $this; 179 | } 180 | 181 | public function getFromProperties($key): mixed 182 | { 183 | Assert::assertSame($this->expectedKey, $key); 184 | return $this->properties[$key]; 185 | } 186 | })->setExpectedKey($propName); 187 | 188 | $this->builder->method('for')->willReturnSelf(); 189 | 190 | $model->__set($propName, $propValue); 191 | 192 | expect($model->__get($propName))->toBe($propValue); 193 | }); 194 | 195 | test('magic get value on model calls getAssociations when HubSpot::isType', function (string $type) { 196 | $this->builder->method('for')->willReturnSelf(); 197 | $testReturn = new Collection(['test' => $this->getName(), 'type' => $type]); 198 | 199 | $model = (new class extends AbstractApiModel { 200 | private string $expectedType; 201 | private Collection $testReturn; 202 | 203 | public function getAssociations($type): Collection 204 | { 205 | Assert::assertSame($this->expectedType, $type); 206 | return $this->testReturn; 207 | } 208 | 209 | public function setTestExpectations(string $expectedType, Collection $testReturn): self 210 | { 211 | $this->expectedType = $expectedType; 212 | $this->testReturn = $testReturn; 213 | return $this; 214 | } 215 | })->setTestExpectations($type, $testReturn); 216 | 217 | expect($model->__get($type))->toBe($testReturn); 218 | 219 | })->with('SdkTypes'); 220 | test('magic get value on model calls getAssociations when HubSpot::isType singular', function (string $singularType) { 221 | $this->builder->method('for')->willReturnSelf(); 222 | $testReturn = new Collection(['test' => $this->getName(), 'type' => $singularType]); 223 | $model = (new class extends AbstractApiModel { 224 | private string $expectedType; 225 | private Collection $testReturn; 226 | 227 | public function getAssociations($type): Collection 228 | { 229 | Assert::assertSame(Str::plural($this->expectedType), $type); 230 | return $this->testReturn; 231 | } 232 | 233 | public function setTestExpectations(string $expectedType, Collection $testReturn): self 234 | { 235 | $this->expectedType = $expectedType; 236 | $this->testReturn = $testReturn; 237 | return $this; 238 | } 239 | })->setTestExpectations($singularType, $testReturn); 240 | 241 | expect($model->__get($singularType)) 242 | ->toBeString() 243 | ->toBe($testReturn->first()); 244 | 245 | })->with('SdkTypes-singular'); 246 | 247 | test('magic get definitions on model calls builder for values', function () { 248 | $this->builder->method('for')->willReturnSelf(); 249 | $mockPropertyDefinition = $this->createMock(PropertyDefinition::class); 250 | $this->builder 251 | ->expects($this->once()) 252 | ->method('definitions') 253 | ->willReturn($mockPropertyDefinition); 254 | 255 | $testCollection = new Collection(['a' => 'b']); 256 | $mockPropertyDefinition 257 | ->expects($this->once()) 258 | ->method('get') 259 | ->willReturn($testCollection); 260 | 261 | $model = new class extends AbstractApiModel { 262 | }; 263 | 264 | expect($model->__get('definitions'))->toBe($testCollection); 265 | }); 266 | test('magic get gets payload if exists', function () { 267 | $this->builder->method('for')->willReturnSelf(); 268 | $propName = sha1(random_bytes(11)); 269 | 270 | $model = (new class extends AbstractApiModel { 271 | private string $expectedKey; 272 | 273 | public function setTestExpectations(string $key, string $value): self 274 | { 275 | $this->expectedKey = $key; 276 | $this->payload[$key] = $value; 277 | return $this; 278 | } 279 | 280 | public function getFromPayload($key, $default = null): mixed 281 | { 282 | Assert::assertSame($this->expectedKey, $key); 283 | Assert::assertNull($default); 284 | return parent::getFromPayload($key, $default); 285 | } 286 | })->setTestExpectations($propName, $this->getName()); 287 | 288 | expect($model->__get($propName)) 289 | ->toBeString() 290 | ->toBe($this->getName()); 291 | }); 292 | 293 | test('magic get returns null if nothing found', function () { 294 | $model = new class extends AbstractApiModel { 295 | }; 296 | expect($model->__get(sha1(random_bytes(11))))->toBeNull(); 297 | }); 298 | 299 | test('type returns internal type', function () { 300 | $this->builder->method('for')->willReturnSelf(); 301 | $model = new class extends AbstractApiModel { 302 | }; 303 | $type = sha1(random_bytes(11)); 304 | $property = new ReflectionProperty($model, 'type'); 305 | $property->setValue($model, $type); 306 | expect($model->type())->toBe($type); 307 | }); 308 | 309 | test('hasNamedScope returns correct value', function () { 310 | $model = new class extends AbstractApiModel { 311 | public function scopeTest() 312 | { 313 | } 314 | }; 315 | 316 | expect($model->hasNamedScope('test')) 317 | ->toBeTrue() 318 | ->and($model->hasNamedScope($this->getName())) 319 | ->toBeFalse(); 320 | }); 321 | 322 | test('toArray returns payload', function () { 323 | $model = new class extends AbstractApiModel { 324 | }; 325 | 326 | $payload = [ 327 | 'test' => $this->getName(), 328 | 'rng' => sha1(random_bytes(11)), 329 | ]; 330 | 331 | $property = new ReflectionProperty($model, 'payload'); 332 | $property->setValue($model, $payload); 333 | 334 | expect($model->toArray())->toBe($payload); 335 | }); 336 | 337 | test('callNamedScope calls Named Scope', function () { 338 | $model = new class extends AbstractApiModel { 339 | public function scopeTestScope(...$parameters) 340 | { 341 | foreach ($parameters as &$value) { 342 | $value++; 343 | } 344 | 345 | return $parameters; 346 | } 347 | }; 348 | 349 | expect($model->callNamedScope('TestScope', range(1, 5))) 350 | ->toBeArray() 351 | ->toBe(range(2, 6)); 352 | }); 353 | 354 | test('__isset returns when property is set', function () { 355 | $model = new class extends AbstractApiModel { 356 | }; 357 | $propName = sha1(random_bytes(11)); 358 | $propValue = sha1(random_bytes(11)); 359 | 360 | expect($model->__isset($propName)) 361 | ->toBeFalse() 362 | ->and(isset($model->$propName)) 363 | ->toBeFalse(); 364 | 365 | $model->$propName = $propValue; 366 | 367 | expect($model->__isset($propName)) 368 | ->toBeTrue() 369 | ->and(isset($model->$propName)) 370 | ->toBeTrue(); 371 | }); 372 | 373 | test('cast casts based on type', function () { 374 | $model = new class extends AbstractApiModel { 375 | }; 376 | $castMethod = new ReflectionMethod($model, 'cast'); 377 | 378 | expect($castMethod->invoke($model, '123', 'int'))->toBeInt() 379 | ->and($castMethod->invoke($model, '2023-02-23', 'datetime'))->toBeInstanceOf(Carbon::class) 380 | ->and($castMethod->invoke($model, '123', 'array'))->toBeArray() 381 | ->and($castMethod->invoke($model, 123, 'string'))->toBeString(); 382 | }); 383 | 384 | test('cast invalid datetime throws exception', function () { 385 | $model = new class extends AbstractApiModel { 386 | }; 387 | $castMethod = new ReflectionMethod($model, 'cast'); 388 | $castMethod->invoke($model, 'abc', 'datetime'); 389 | })->throws(InvalidFormatException::class); 390 | 391 | test('cast to string throws exception on array', function () { 392 | $model = new class extends AbstractApiModel { 393 | }; 394 | $castMethod = new ReflectionMethod($model, 'cast'); 395 | $castMethod->invoke($model, ['abc'], 'string'); 396 | })->throws(ErrorException::class, 'Array to string conversion'); 397 | 398 | test('cast to int does weird', function () { 399 | $model = new class extends AbstractApiModel { 400 | }; 401 | $castMethod = new ReflectionMethod($model, 'cast'); 402 | expect($castMethod->invoke($model, ['abc'], 'int'))->toBe(1) 403 | ->and($castMethod->invoke($model, '0x16', 'int'))->toBe(0); 404 | }); 405 | 406 | test('getFromPayload returns casted properties', function () { 407 | $model = new class extends AbstractApiModel { 408 | private string|null $expectedType; 409 | 410 | protected function cast($value, $type = null): mixed 411 | { 412 | Assert::assertSame($this->expectedType, $type); 413 | return parent::cast($value, $type); 414 | } 415 | 416 | public function setExpected($key, $value = null, $type = null) 417 | { 418 | if ($type !== null) { 419 | $this->schema[$key] = $type; 420 | $this->expectedType = $type; 421 | } elseif (array_key_exists($key, $this->schema)) { 422 | $this->expectedType = $this->schema[$key]; 423 | } 424 | 425 | if ($value !== null) { 426 | $this->payload[$key] = $value; 427 | } 428 | return $this; 429 | } 430 | }; 431 | 432 | $idValue = random_int(11, 99); 433 | expect( 434 | $model->setExpected('id', value: (string)$idValue)->getFromPayload('id') 435 | )->toBe($idValue); 436 | }); 437 | 438 | test('delete calls builder delete and sets exists false', function () { 439 | $model = new class extends AbstractApiModel { 440 | public bool $exists = true; 441 | }; 442 | 443 | $this->builder->method('for')->willReturnSelf(); 444 | $this->builder 445 | ->expects($this->once()) 446 | ->method('delete'); 447 | 448 | expect($model->delete()) 449 | ->toBeTrue() 450 | ->and($model->exists) 451 | ->toBeFalse(); 452 | }); 453 | 454 | test('method calls are forwarded to associations for hubspot types', function (string $type) { 455 | $model = (new class extends AbstractApiModel { 456 | 457 | private string $expectedType; 458 | 459 | public function associations($type): Association 460 | { 461 | Assert::assertSame($this->expectedType, $type); 462 | return parent::associations($type); 463 | } 464 | 465 | public function setExpected(string $expectedType): self 466 | { 467 | $this->expectedType = $expectedType; 468 | return $this; 469 | } 470 | 471 | })->setExpected($type); 472 | 473 | $model->$type(); 474 | })->with('SdkTypes'); 475 | 476 | test('static calls get forwarded', function () { 477 | $model = new class extends AbstractApiModel { 478 | protected function mylittletestfunction(string $testMessage) 479 | { 480 | $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; 481 | Assert::assertSame('__callStatic', $trace['function']); 482 | Assert::assertSame(AbstractApiModel::class, $trace['class']); 483 | Assert::assertSame('this is a test', $testMessage); 484 | } 485 | }; 486 | 487 | $model::mylittletestfunction('this is a test'); 488 | }); 489 | 490 | test('only calls getFromProperties for each item passed', function () { 491 | $model = new class extends AbstractApiModel { 492 | public const TESTVALUES = ['a' => 1, 'yes' => 2, 'b' => 3, 'no' => 4, 'k' => 5]; 493 | 494 | public function getFromProperties($key): mixed 495 | { 496 | Assert::assertArrayHasKey($key, static::TESTVALUES); 497 | return static::TESTVALUES[$key]; 498 | } 499 | }; 500 | 501 | $this->assertSame($model::TESTVALUES, $model->only(array_keys($model::TESTVALUES))); 502 | $this->assertSame($model::TESTVALUES, $model->only(...array_keys($model::TESTVALUES))); 503 | }); 504 | 505 | test('getFromProperties returns item when instance of Property', function () { 506 | $model = new class extends Property { 507 | }; 508 | 509 | $this->builder->method('for')->willReturnSelf(); 510 | $this->builder->expects($this->never())->method('definitions'); 511 | 512 | $model->testProperty = sha1(random_bytes(11)); 513 | $this->assertSame($model->testProperty, $model->getFromProperties('testProperty')); 514 | }); 515 | 516 | test('getFromProperties returns item when definitions is missing key ', function () { 517 | $model = new class extends AbstractApiModel { 518 | }; 519 | 520 | $propertyDefinition = $this->createMock(PropertyDefinition::class); 521 | $collectionMock = $this->createMock(Collection::class); 522 | $this->builder->method('for')->willReturnSelf(); 523 | $this->builder->method('definitions')->willReturn($propertyDefinition); 524 | $propertyDefinition->method('get')->willReturn($collectionMock); 525 | $collectionMock 526 | ->expects($this->once()) 527 | ->method('has') 528 | ->with('testProperty') 529 | ->willReturn(false); 530 | 531 | $sha1 = sha1(random_bytes(11)); 532 | $model->testProperty = $sha1; 533 | $this->assertSame($sha1, $model->getFromProperties('testProperty')); 534 | }); 535 | 536 | test('getFromProperties returns unserialized definition when definitions is matches key', function () { 537 | $model = new class extends AbstractApiModel { 538 | }; 539 | 540 | $sha1 = sha1(random_bytes(11)); 541 | $sha2 = sha1(random_bytes(11)); 542 | $model->testProperty = $sha1; 543 | 544 | $propertyDefinition = $this->createMock(PropertyDefinition::class); 545 | $collectionMock = $this->createMock(Collection::class); 546 | $mockProperty = $this->createMock(Property::class); 547 | $this->builder->method('for')->willReturnSelf(); 548 | $this->builder->method('definitions')->willReturn($propertyDefinition); 549 | $propertyDefinition->method('get')->willReturn($collectionMock); 550 | $collectionMock 551 | ->expects($this->once()) 552 | ->method('has') 553 | ->with('testProperty') 554 | ->willReturn(true); 555 | $collectionMock->expects($this->once()) 556 | ->method('get') 557 | ->with('testProperty') 558 | ->willReturn($mockProperty); 559 | 560 | $mockProperty->expects($this->once()) 561 | ->method('unserialize') 562 | ->with($sha1) 563 | ->willReturn($sha2); 564 | 565 | $this->assertSame($sha2, $model->getFromProperties('testProperty')); 566 | }); 567 | 568 | test('endpoint replaces type with model property', function () { 569 | $model = new class extends AbstractApiModel { 570 | protected string $type = 'testing'; 571 | protected array $endpoints = ['test_abc' => '/test/{type}/testing']; 572 | }; 573 | $this->assertSame('/test/testing/testing', $model->endpoint('test_abc')); 574 | $this->assertSame('/test/testing/testing', $model->endpoint('test_abc', ['type' => 'other type'])); 575 | }); 576 | 577 | test('endpoint replaces id with payload id', function () { 578 | $model = new class extends AbstractApiModel { 579 | protected string $type = 'testing'; 580 | protected array $payload = ['id' => '123']; 581 | protected array $endpoints = ['test_abc' => '/test/{type}/{id}']; 582 | }; 583 | $this->assertSame('/test/testing/123', $model->endpoint('test_abc')); 584 | $this->assertSame('/test/testing/123', $model->endpoint('test_abc', ['id' => '222'])); 585 | }); 586 | 587 | test('endpoint replaces endpoint keys with passed fill', function () { 588 | $model = new class extends AbstractApiModel { 589 | protected string $type = 'testing'; 590 | protected array $endpoints = ['test_abc' => '/test/{type}/{param1}/param2/{param3}']; 591 | }; 592 | $time = now()->toDateTimeString(); 593 | $endpoint = $model->endpoint( 594 | 'test_abc', 595 | [ 596 | 'param1' => 'eter1', 597 | 'param2' => 'ignored', 598 | 'param3' => $time 599 | ] 600 | ); 601 | $this->assertSame('/test/testing/eter1/param2/' . $time, $endpoint); 602 | }); 603 | 604 | test('getDirty returns properties missing from payload', function () { 605 | $model = new class extends AbstractApiModel { 606 | protected array $payload = ['properties' => []]; 607 | protected array $properties = ['test' => 123]; 608 | }; 609 | $this->assertSame(['test' => 123], $model->getDirty()); 610 | }); 611 | 612 | test('getDirty returns properties changed from payload', function () { 613 | $model = new class extends AbstractApiModel { 614 | protected array $payload = ['properties' => ['test' => 123]]; 615 | protected array $properties = ['test' => 321]; 616 | }; 617 | $this->assertSame(['test' => 321], $model->getDirty()); 618 | }); 619 | 620 | test('hydrate calls init on new instance', function () { 621 | $model = new class extends AbstractApiModel { 622 | 623 | public static array $expectedPayload = []; 624 | 625 | protected function init(array $payload = []): static 626 | { 627 | Assert::assertSame(self::$expectedPayload, $payload); 628 | return $this; 629 | } 630 | }; 631 | $model::$expectedPayload = ['test' => $this->getName(), 'when' => now()->toDateTimeString()]; 632 | 633 | $secondModel = $model::hydrate($model::$expectedPayload); 634 | $this->assertInstanceOf(AbstractApiModel::class, $secondModel); 635 | }); 636 | 637 | test('init calls fill, set exists and sets payload', function () { 638 | $model = new class extends AbstractApiModel { 639 | public static array $expectedPayload = []; 640 | 641 | public function fill(array $properties): static 642 | { 643 | Assert::assertSame(self::$expectedPayload['properties'], $properties); 644 | return parent::fill($properties); // TODO: Change the autogenerated stub 645 | } 646 | }; 647 | 648 | $model::$expectedPayload = [ 649 | 'id' => random_int(100, 999), 650 | 'properties' => [ 651 | 'test' => $this->getName(), 652 | 'when' => now()->toDateTimeString() 653 | ] 654 | ]; 655 | 656 | $initMethod = new ReflectionMethod($model, 'init'); 657 | $payloadProperty = new ReflectionProperty($model, 'payload'); 658 | $result = $initMethod->invoke($model, $model::$expectedPayload); 659 | 660 | $this->assertSame($model::$expectedPayload, $payloadProperty->getValue($model)); 661 | $this->assertTrue($result->exists); 662 | }); 663 | 664 | 665 | test('expand calls through as expected', function () { 666 | $model = new class extends AbstractApiModel { 667 | public static array $expectedPayload = []; 668 | protected array $properties = ['id' => 123]; 669 | 670 | protected function init(array $payload = []): static 671 | { 672 | Assert::assertSame(self::$expectedPayload, $payload); 673 | return parent::init($payload); 674 | } 675 | }; 676 | 677 | $model::$expectedPayload = [ 678 | 'id' => random_int(100, 999), 679 | 'properties' => [ 680 | 'test' => $this->getName(), 681 | 'when' => now()->toDateTimeString() 682 | ] 683 | ]; 684 | 685 | $this->builder->method('for')->willReturnSelf(); 686 | $mockPropertyDefinition = $this->createMock(PropertyDefinition::class); 687 | $this->builder 688 | ->expects($this->once()) 689 | ->method('definitions') 690 | ->willReturn($mockPropertyDefinition); 691 | 692 | $mockPropertyDefinition 693 | ->expects($this->once()) 694 | ->method('get') 695 | ->willReturn(new Collection()); 696 | $this->builder->expects($this->once())->method('full')->willReturnSelf(); 697 | $this->builder->expects($this->once())->method('item')->with(123)->willReturn($model::$expectedPayload); 698 | $model->expand(); 699 | }); 700 | 701 | test('create calls hydrate with builder create properties', function () { 702 | $model = new class extends AbstractApiModel { 703 | public static array $expectedPayload = []; 704 | 705 | public static function hydrate(array $payload = []): static 706 | { 707 | $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; 708 | Assert::assertSame('create', $trace['function']); 709 | Assert::assertSame('::', $trace['type']); 710 | Assert::assertSame(AbstractApiModel::class, $trace['class']); 711 | return parent::hydrate($payload); 712 | } 713 | }; 714 | $properties = [ 715 | 'test' => $this->getName(), 716 | 'when' => now()->toDateTimeString() 717 | ]; 718 | $model::$expectedPayload = [ 719 | 'id' => random_int(100, 999), 720 | 'properties' => $properties 721 | ]; 722 | 723 | $this->builder->method('for')->willReturnSelf(); 724 | $this->builder->expects($this->once())->method('create')->with($properties)->willReturn($model::$expectedPayload); 725 | 726 | $model::create($properties); 727 | }); 728 | 729 | test('save when exists is true', function () { 730 | $model = new class extends AbstractApiModel { 731 | public bool $exists = true; 732 | public static array $expectedPayload = []; 733 | 734 | public function fill(array $properties): static 735 | { 736 | $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; 737 | Assert::assertSame('save', $trace['function']); 738 | Assert::assertSame(AbstractApiModel::class, $trace['class']); 739 | return parent::fill($properties); 740 | } 741 | }; 742 | 743 | $properties = [ 744 | 'test' => $this->getName(), 745 | 'when' => now()->toDateTimeString() 746 | ]; 747 | $model::$expectedPayload = [ 748 | 'id' => random_int(100, 999), 749 | 'properties' => $properties 750 | ]; 751 | 752 | $this->builder->method('for')->willReturnSelf(); 753 | $this->builder->expects($this->once())->method('update')->with([])->willReturn($model::$expectedPayload); 754 | $this->builder->expects($this->never())->method('create')->withAnyParameters(); 755 | 756 | $model->save(); 757 | }); 758 | 759 | test('save when exists is false', function () { 760 | $model = new class extends AbstractApiModel { 761 | public bool $exists = false; 762 | public static array $expectedPayload = []; 763 | 764 | 765 | protected function init(array $payload = []): static 766 | { 767 | $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; 768 | Assert::assertSame('save', $trace['function']); 769 | Assert::assertSame(AbstractApiModel::class, $trace['class']); 770 | return parent::init($payload); 771 | } 772 | }; 773 | 774 | $properties = [ 775 | 'test' => $this->getName(), 776 | 'when' => now()->toDateTimeString() 777 | ]; 778 | $model::$expectedPayload = [ 779 | 'id' => random_int(100, 999), 780 | 'properties' => $properties 781 | ]; 782 | $model->fill($properties); 783 | $this->builder->method('for')->willReturnSelf(); 784 | $this->builder->expects($this->never())->method('update')->withAnyParameters(); 785 | $this->builder->expects($this->once())->method('create')->with($properties)->willReturn($model::$expectedPayload); 786 | 787 | $model->save(); 788 | }); 789 | --------------------------------------------------------------------------------