Blog

Esme Acevedo

May 16, 2023

Spread the word


Share your thoughts

Introducing ColdBox 7: Revolutionizing Web Development with Cutting-Edge Features and Unparalleled Performance

We are thrilled to announce the highly anticipated release of ColdBox 7, the latest version of the acclaimed web development HMVC framework for ColdFusion (CFML). ColdBox 7 introduces groundbreaking features and advancements, elevating the development experience to new heights and empowering developers to create exceptional web applications and APIs.

Designed to meet the evolving needs of modern web development, ColdBox 7 boasts a range of powerful features that streamline the development process and enhance productivity. With its robust HMVC architecture and developer-friendly tools, ColdBox 7 enables developers to deliver high-performance, scalable, and maintainable web applications and APIs with ease.

Key Features of ColdBox 7:

  1. ColdBox HMVC: The Hierarchical Model-View-Controller (HMVC) architecture allows developers to build complex applications by organizing modules into a structured hierarchy. This promotes code reusability, enhances modularity, and facilitates collaboration among developers.

  2. ColdBox CLI: ColdBox 7 seamlessly integrates with CommandBox, a powerful command-line interface and package manager for ColdFusion. This integration simplifies dependency management, streamlines application setup, and enhances the overall development workflow with code generation.

  3. WireBox Enhancements: WireBox has been completely streamlined and every line of code profiled to squeeze the last bit of performance for object creation and wiring. It also introduce powerful new object oriented concepts such as Object Delegation, Lazy Properties and Property Observers.

  4. Modular Injectors: ColdBox 7 introduces an experimental feature were each module can have its own hierarchy of WireBox injectors. This allows for the encapsulation of dependencies within a module, which will allow for multi-versioned modules to co-exist within a ColdBox application.

  5. Scheduled Tasks: ColdBox 7 introduces even more options when declaring and runing scheduled tasks in a portable and human way. It also lays the foundation in its API for CBTasks HQ, a module to visualize and manage scheduled tasks in a cluster.

Developers can access comprehensive documentation, tutorials, and sample applications to jumpstart their development process and leverage the full potential of the framework: https://coldbox.ortusbooks.com/

In this blog post we will review the major updates of this release, but for the full what's new, please refer to our what's new guide: https://coldbox.ortusbooks.com/intro/release-history/whats-new-with-7.0.0

Installing ColdBox, WireBox, CacheBox or LogBox

You can leverage CommandBox to install any of our frameworks via the CLI:

install coldbox

# If you are using standalone libraries, then install those
install wirebox
install cachebox
install logbox

Updating ColdBox, WireBox, CacheBox, LogBox

update coldbox

# If you are using standalone libraries, then update those
update wirebox
update cachebox
update logbox

LTS Support

For all ColdBox releases, updates are provided for 12 months and security fixes are provided for 2 years after the next major release.

VersionReleaseUpdatesSecurity Fixes
6.x202220232025
7.x202320242026
8.x202420252027
9.x202520262028

Engine Support

This release drops support for Adobe 2016 and adds support for Adobe 2023 and Lucee 6 (Beta). Please note that there are still issues with Adobe 2023 as it is still in Beta.

ColdBox CLI

We now have an official CLI for ColdBox, which lives outside CommandBox. It will always be included with CommandBox, but it now has its own life cycles, and it will support each major version of ColdBox as well.

install coldbox-cli
coldbox --help

The new CLI has all the previous goodness but now also v7 support and many other great features like migration creation, API testing, and more. You can find the source for the CLI here: https://github.com/coldbox/coldbox-cli

VSCode ColdBox Extension

We have updated our VSCode ColdBox extension now to support the latest version of snippets and skeletons for code generation.

Application Templates

All the application templates have been updated in our ColdBox Templates Org: https://github.com/coldbox-templates. They have been updated to support our LTS strategy, so now we can have templates based on each major iteration of ColdBox.

Here is a listing of the latest supported templates:

TemplateSlugDescription
DefaultdefaultThe default ColdBox application template
ElixirelixirThe default template with ColdBox elixir support for asset pipelines
Modern (experimental)modernA fresh new approach to ColdBox applications that are non-root based. Still experimental
RestrestA base REST API using ColdBox
Rest HMVCrest-hmvcAn HMVC REST API using modules
Super SimplesupersimpleBarebones conventions baby!

WireBox Updates

 __          ___          ____
 \ \        / (_)        |  _ \
  \ \  /\  / / _ _ __ ___| |_) | _____  __
   \ \/  \/ / | | '__/ _ \  _ < / _ \ \/ /
    \  /\  /  | | | |  __/ |_) | (_) >  <
     \/  \/   |_|_|  \___|____/ \___/_/\_\

WireBox has gotten tons of love in this release, with several additions, bug fixes, and improvements.

Transient Request Cache

This feature is one of the most impactful for applications that leverage DI on transient objects, especially ORM-related applications. WireBox will now, by default, cache the signatures of the injections and delegations for you, so they are only done once per instance type. This addition has brought speed improvements of over 585% in Lucee and Adobe ColdFusion. You read that right, 585% performance increases. This is really a game changer for ORM-heavy applications that use DI and delegations. Go try it; you don't have to do a thing. Install and run it!

If this is not for you or there are issues in your system because of it, we have a setting for it to turn it off. Open the WireBox.cfc binder and add it as a config item.

// Config DSL
wirebox : {
    transientInjectionCache : false
}

// Binder Call
binder.transientInjectionCache( false )

You can also disable the cache on a per-CFC basis by using the transientCache=false annotation in your component declaration:

component transientCache=false{

}

WireBox Delegators

WireBox supports the concept of object delegation in a simple, expressive DSL. You can now add a delegate annotation to injections or use the delegates annotations to components to inject and absorb the object's methods into yourself.

// Inject and use as a delegate
property name="memory" inject delegate

// Delegate Component
component name="computer" delegates="Memory"{
}

In object-oriented programming, an object delegator is a programming technique where an object delegates some of its responsibilities to another object. The delegating object passes the responsibility for handling a particular task to the delegate object. This allows the delegating object to focus on its core responsibilities while the delegate object handles the delegated task.

Basically, a way to inject/proxy calls from one object to the other and avoid the overuse of inheritance, and avoid runtime mixins. WireBox provides a set of rules for method lookup and dispatching that will allow you to provide delegation easily in your CFML applications. This feature is similar to traits in PHP or object delegators in Kotlin.

You can use it to encapsulate behavior on small, focused, and testable classes that can be brought in as traits into ANY component without abusing inheritance. In contrast, object delegation is a more flexible approach that allows objects to delegate tasks to any other object, regardless of its class hierarchy. Finally, object delegation can help to improve code performance by allowing objects to use specialized delegate objects for specific tasks.

Let's look at an example of how we would use delegation without this feature:

  component name="Memory"{
  
  function init(){
    return reset()
  }
  
  function reset(){
    variables.data = []
    return this;
  }
  
  function read( index ){
    return variables.data[ arguments.index ]
  }
  
  function write( data ){
    variables.data.append( arguments.data )
  }
  
}
  

Now let's look at the computer

component name="computer"{

    // Inject a memory object via WireBox
    property name="memory" inject;
    
    // read delegator proxy method
    function read( index ){
        return variables.memory.read( argumentCollection = arguments )
    }
    
    // write delegator proxy method
    function write( data ){
        return variables.memory.read( argumentCollection = arguments )
    }

}

As you can see, in the traditional approach we must type and inject and know every detail of the delegated methods. Now let's delegalize it via WireBox:

component name="computer"{
  // Inject and use as a delegate
  property name="memory" inject delegate

}

computer = getInstance( "Computer" )
computer.read( index )
computer.write( data )

Or use the shorthand notation via the delegates annotation of components:

component name="computer" delegates="Memory"{

   // code

}

computer = getInstance( "Computer" )
computer.read( index )
computer.write( data )

You can also do prefixes, suffixes, method includes, excludes, and even add as many delegates as you want:

component name="computer"
	delegates=">Memory, <Disk=read,sleep"
}

component name="computer"{

   property name="authorizable." 
	inject="provider:Authorizable@cbsecurity"
	delegate;

}

Read more about delegates here: https://wirebox.ortusbooks.com/usage/wirebox-delegators

Core Delegates

Now that we have seen what delegators are, WireBox offers core delegators to your application via the @coreDelegates namespace

  • Async - This delegate is useful to interact with the AsyncManager and the most used functionality for asynchronous programming
  • DateTime - Leverage the date time helper
  • Env - Talk to environment variables
  • Flow - Several fluent flow methods
  • JsonUtil - JSON utilities
  • StringUtil - String utilities
  • Population - Population utilities

So let's say you have a service that needs to populate objects and work with the system environment:

component 
    delegates="population@coreDelegates, Env@coreDelegates"{
}

WireBox Property Observers

WireBox supports the concepts of component property observers. Meaning that you can define a function that will be called for you when the setter for that property has been called and thus observe the property changes.

You will accomplish this by tagging a property with an annotation called observed then by convention, it will look for a function called: {propertyName}Observer by convention. This function will receive three arguments:

  • newValue : The value is set into the property
  • oldValue : The old value of the property, including null
  • property : The name of the property
component{

  property name="data" observed;
  
  /**
   * Observer for data changes.  Anytime data is set, it will be called
     	 *
   * @new The new value
   * @old The old value
   * @property The name of the property observed
   */
  function dataObserver( newValue, oldValue, property ){
  	// Execute after data is set
  }

}

WireBox Lazy Properties

WireBox supports the concept of marking properties in your components as lazy. This will allow the property to be constructed ONCE when requested ONLY (lazy loaded). This way, you can take advantage of the construction of the property being lazy-loaded.

Internally, we will generate a getter method for you that will make sure to construct your property via a builder function you will provide, lock the request (by default), store it in the variables scope, and return it to you.

Note: With lazy properties, you must use the getter only to retrieve the property

component{
	
  // Lazy property: Constructed by convention via the buildUtil() method
  property name="util" lazy;
  
  /**
   * Build a util object lazyily.
   * The first time you call it, it will lock, build it, and store it by convention as 'variables.util'
   */
  function buildUtil(){
   return new coldbox.system.core.util.Util();
  }

}

Read more about Lazy Properties here: https://wirebox.ortusbooks.com/advanced-topics/lazy-properties

onInjectorMissingDependency event

A new event called onInjectorMissingDependency is now registered in Wirebox. It will be called whenever a dependency cannot be located. The data sent into the event will contain:

  • name - The name of the requested dependency
  • initArguments - The init arguments, if passed
  • targetObject - The target object that requested the dependency
  • injector - The injector in use building the dependency

If you return in the data struct an element called, instance we will return that as the dependency, else the normal exception will be thrown.

// Simple listener example
listen( "onInjectorMissingDependency", (data,event,rc,prc)=>{
    if( data.name == "OldLegacyOne" ){
        data.instance = myNewObject();
    }
});

Population Enhancements

  • The object populator now caches ORM entity maps, so they are only loaded once, and the population with ORM objects accelerates tremendously.
  • The object populator caches relational metadata for a faster population of the same type of objects

Mass Population Config: this.population

This new convention allows for objects to encapsulate the way the mass population of data is treated. This way, you don’t have to scrub or pass include excludes lists via population arguments; it can all be nicely encapsulated in the targeted objects:

this.population = {
    include : [ "firstName", "lastName", "username", "role" ],
    exclude : [ "id", "password", "lastLogin" ]
};

The populator will look for a this.population struct with the following keys:

  • include : an array of property names to allow population ONLY
  • exclude : an array of property names to NEVER allow population

The population methods also get a new argument called: ignoreTargetLists which defaults to false, meaning it inspects the objects for these population markers. If you pass it as true then the markers will be ignored. This is great for the population of objects from queries or an array of structs or mementos that YOU have control of.

populateFromStruct(
    target : userService.newUser(),
    memento : record,
    ignoreTargetLists : true
}

Schedule Tasks Revamped

Thanks to Giancarlo Gomez, scheduled tasks get a big revamp in ColdBox 7. Here are the updates:

New Properties

  • annually
    You can now task on an annual basis
  • delayTimeUnit
    used to work with delays regardless of the setting in the chain
  • debug
    used for debug output during task executions
  • firstBusinessDay
    boolean to flag the task as on the first business day schedule
  • lastBusinessDay
    boolean to flag the task as on the last business day schedule
  • taskTime
    log of time of day for first business day and last business day tasks
  • startTime
    limits tasks to run on or after a specific time of day
  • endTime
    limits tasks to run on or before a specific time of day
  • meta
    The user can use this struct to store any data for the task ( helpful for building UIs and not having to manage outside of the task )
  • stats.nextRun
    ColdBox now determines the next run for the task in its scheduler interval

Updated Functions

  • run( boolean force = false )
    Added the force argument, to allow running the task on demand, even if it is paused.
  • validateTime()
    Sets minutes if missing from time entry and returns time value if successful - removes a lot of repetitive code

New Functions

  • debug()
    Used for setting debug setting ( can also be done in task init )
  • startOnTime()
    used to set variables.startTime
  • endOnTime()
    used to set variables.endTime
  • between()
    used to set variables.startTime and variables.endTime in one call
  • setMeta()
    used to set variables.meta
  • setMetaKey()
    used to add / save a key to variables.meta
  • deleteMetaKey()
    used to delete a key from variables.meta

New Private Functions

  • getLastBusinessDayOfTheMonth()
  • getFirstBusinessDayOfTheMonth()
  • setInitialNextRunTime()
  • setInitialDelayPeriodAndTimeUnit()
  • setNextRunTime()
  • debugLog()

Module Updates

Config Object Override

In ColdBox 7, you can now store the module configurations outside of the config/Coldbox.cfc. Especially in an application with many modules and many configs, the modulesettings would get really unruly and long. Now you can bring separation. This new convention will allow module override configurations to exist as their own configuration file within the application’s config folder.

config/modules/{moduleName}.cfc

The configuration CFC will have one configure() method that is expected to return a struct of configuration settings as you did before in the moduleSettings

component{

    function configure(){
        return {
            key : value
        };
    }

}

Injections

Just like a ModuleConfig this configuration override also gets many injections:

* controller
* coldboxVersion
* appMapping
* moduleMapping
* modulePath
* logBox
* log
* wirebox
* binder
* cachebox
* getJavaSystem
* getSystemSetting
* getSystemProperty
* getEnv
* appRouter
* router

Module Injectors

We have an experimental feature in ColdBox 7 to enable per-module injectors. This will create a hierarchy of injections and dependency lookups for modules. This is in preparation for future capabilities to allow for multi-named modules in an application. In order to activate this feature you need to use the this.moduleInjector = true in your ModuleConfig.cfc

this.moduleInjector = true

Once enabled, please note that your module injector will have a unique name, and a link to the parent and root injectors and will ONLY know about itself and its children. Everything will be encapsulated under itself. No more global dependencies, we are now in module-only dependencies. This means each module must declare its dependencies beforehand.

ModuleConfig Injections

If the module injector is enabled, you will also get different injections in your ModuleConfig

InjectionDescription
binderThe root injector binder
rootWireBoxThe root injector or global injector.
wireboxThis is now a reference to the module's injector.

Root Helpers

The supertype also has methods to interact with the root and module injector getRootWireBox() and getWireBox() for the module injector.

getRootWireBox().getInstance( "GlobalModel" )

Injection Awareness

Every module also can inject/use its models without the need to namespace them.

// Before, using the @contacts address
property name="contactService" inject="contactService@contacts";

// If using module injectors
property name="contactService" inject="contactService";

Config/Settings Awareness

You can now use the {this} placeholder in injections for module configurations-settings, and ColdBox will automatically replace it with the current module it's being injected in:

property name="mySettings" inject="coldbox:moduleSettings: {this}"
property name="myConfig" inject="coldbox:moduleConfig: {this}"

This is a great way to keep your injections clean without adhering to the module name.

ColdBox Delegates

Since WireBox introduced delegates, we have taken advantage of this great reusable feature throughout ColdBox. We have also created several delegates for your convenience that can be used via its name and the @cbDelegates namespace:

DelegatePurpose
AppModesMethods to let you know in which tier you are on and more
InterceptorAnnounce interceptions
LocatorsLocate files and/or directories
PopulationPopulate objects
RenderingRender views and layouts
SettingsInteract with ColdBox/Module Settings

Let's say you have a security service that needs to get settings, announce events and render views:

component name="SecurityService"
    delegates="settings@cbDelegates, rendering@cbDelegates, interceptor@cbDelegates"{
    
    function login(){
        if( getSetting( "logEnabled" ) ){
            ... 
            
            var viewInfo = view( "security/logdata" );
            
        }
        
        ...
        
        announce( "onLogin" );
    }

}

Here you can find more information about the CBDelegates: https://s3.amazonaws.com/apidocs.ortussolutions.com/coldbox/7.0.0/coldbox/system/web/delegates/package-summary.html

User Identifier Providers

In previous versions of ColdBox, it would auto-detect unique request identifiers for usage in Flash Ram, storages, etc., following this schema:

  1. If we have session enabled, use the jessionId or session URL Token
  2. If we have cookies enabled, use the cfid/cftoken
  3. If we have in the URL the cfid/cftoken
  4. Create a unique request-based tracking identifier: cbUserTrackingId

However, you can now decide what will be the unique identifier for requests, flash RAM, etc by providing it via a coldbox.identifierProvider as a closure/lambda in your config/Coldbox.cfc

coldbox : {
    ...
    
    identifierProvider : () => {
        // My own logic to provide a unique tracking id
        return myTrackingID
    }
    
    ...

}

If this closure exists, ColdBox will use the return value as the unique identifier. A new method has also been added to the ColdBox Controller so you can retrieve this value:

controller.getUserSessionIdentifier()

The supertype as well so all handlers/layouts/views/interceptors can get the user identifier:

function getUserSessionIdentifier()

App Mode Helpers

ColdBox 7 introduces opinionated helpers to the FrameworkSuperType so you can determine if you are in three modes: production, development, and testing by looking at the environment setting:

function isProduction()
function isDevelopment()
function isTesting()
ModeEnvironment
inProduction() == trueproduction
inTesting() == truetesting
inDevelopment() == truedevelopment or local

These super-type methods delegate to the ColdBox Controller. So that means that if you needed to change their behavior, you could do so via a Controller Decorator.

You can also use them via our new AppModes@cbDelegates delegate in any model:

component delegate="AppModes@cbDelegates"{}

Resource Route Names

When you register resourceful routes now, they will get assigned a name so you can use it for validation via routeIs() or for route link creation via route()

VerbRouteEventRoute Name
GET/photosphotos.indexphotos
GET/photos/newphotos.newphotos.new
POST/photosphotos.createphotos
GET/photos/:idphotos.showphotos.process
GET/photos/:id/editphotos.editphotos.edit
PUT/PATCH/photos/:idphotos.updatephotos.process
DELETE/photos/:idphotos.deletephotos.process

Baby got back()!

The framework super type now sports a back() function so you can use it to redirect back to the referer in a request.

function save( event, rc, prc ){
    ... save your work
    
    // Go back to where you came from
    back();
}

RequestContext Routing/Pathing Enhancements

The RequestContext has a few new methods to assist you when working with routes, paths, and URLs.

MethodPurpose
routeIs( name ):booleanVerify if the passed name is the current route
getUrl( withQuery:boolean )Returns the entire URL, including the protocol, host, mapping, path info, and query string.
getPath( withQuery:boolean )Return the relative path of the current request.
getPathSegments():arrayGet all of the URL path segments from the requested path.
getPathSegment( index, defaultValue )Get a single path segment by position

Native DateHelper

The FrameworkSuperType now has a getDateTimeHelper(), getIsoTime() methods to get access to the ColdBox coldbox.system.async.time.DateTimeHelper to assist you with all your date/time/timezone needs and generate an iso8601 formatted string from the incoming date/time.

getDateTimeHelper().toLocalDateTime( now(), "Americas/Central" )
getDateTimeHelper().getSystemTimezone()
getDateTimeHelper().parse( "2018-05-11T13:35: 11Z" )
getIsoTime()

You can also use the new date time helper as a delegate in your models:

component delegate="DateTime@coreDelegates"{

}

Whoops! Upgrades

Whoops got even more love:

  • SQL Syntax Highlighting
  • JSON Pretty Printing
  • JSON highlighting
  • Debug Mode to show source code
  • Production detection to avoid showing source code
  • Rendering performance improvements

RESTFul Exception Responses

The RESTFul handlers have been updated to present more useful debugging when exceptions are detected in any call to any resource. You will now get the following new items in the response:

  • environment
    • A snapshot of the current routes, URLs and event
  • exception
    • A stack frame, detail, and type
{
  "data": {
    "environment": {
      "currentRoutedUrl": "restfulHandler/anError/",
      "timestamp": "2023-04-26T20:21: 05Z",
      "currentRoute": ":handler/:action/",
      "currentEvent": "restfulHandler.anError"
    },
    "exception": {
      "stack": [
        "/Users/lmajano/Sites/projects/coldbox-platform/system/web/context/RequestContext.cfc:1625",
        "/Users/lmajano/Sites/projects/coldbox-platform/test-harness/handlers/restfulHandler.cfc:42",
        "/Users/lmajano/Sites/projects/coldbox-platform/system/RestHandler.cfc:58",
        "/Users/lmajano/Sites/projects/coldbox-platform/system/web/Controller.cfc:998",
        "/Users/lmajano/Sites/projects/coldbox-platform/system/web/Controller.cfc:713",
        "/Users/lmajano/Sites/projects/coldbox-platform/test-harness/models/ControllerDecorator.cfc:71",
        "/Users/lmajano/Sites/projects/coldbox-platform/system/Bootstrap.cfc:290",
        "/Users/lmajano/Sites/projects/coldbox-platform/system/Bootstrap.cfc:506",
        "/Users/lmajano/Sites/projects/coldbox-platform/test-harness/Application.cfc:90"
      ],
      "detail": "The type you sent dddd is not a valid rendering type. Valid types are JSON,JSONP,JSONT,XML,WDDX,TEXT,PLAIN,PDF",
      "type": "RequestContext.InvalidRenderTypeException",
      "extendedInfo": ""
    }
  },
  "error": true,
  "pagination": {
    "totalPages": 1,
    "maxRows": 0,
    "offset": 0,
    "page": 1,
    "totalRecords": 0
  },
  "messages": [
    "An exception ocurred: Invalid rendering type"
  ]
}

ColdBox DebugMode

ColdBox now has a global debugMode setting, which is used internally for presenting sources in Whoops and extra debugging parameters in our RESTFul handler. However, it can also be used by module developers or developers to dictate if your application is in debug mode or not. This is just a fancy way to say we trust the user doing the requests.

{% hint style="info" %} The default value is false {% endhint %}

coldbox : {
    ...
    
    debugMode : true
    
    ...

}

You also have a inDebugMode() method in the main ColdBox Controller and the FrameworkSuperType that will let you know if you are in that mode or not.

boolean function inDebugMode()

// Debug mode report
if( inDebugMode() ){
    include "BugReport.cfm";
}

You can also use the AppModes@cbDelegates delegate to get access to the inDebugMode and other methods in your models:

component delegates="AppModes@cbDelegates"{

    ...
    if( inDebugMode() ){
    
    }
    ...

}

Closure Logging

In previous versions, you had to use the canxxxx method to determine if you can log at a certain level:

if( log.canInfo() ){
    log.info( "This is a log message", data );
}

if( log.canDebug() ){
    log.debug( "This is a debug message", data );
}

In ColdBox 7, you can use a one-liner by leveraging a lambda function:

log.info( () => "This is a log message", data )
log.debug( () => "This is a debug message", data )

Read More

This is just the tip of the iceberg of all the great features and enhancements of ColdBox 7. Please read more here: https://coldbox.ortusbooks.com/intro/release-history/whats-new-with-7.0.0

Add Your Comment

Recent Entries

Elevate Your ColdBox Experience and Skills

Elevate Your ColdBox Experience and Skills

We're thrilled to announce a significant overhaul of our ColdBox training experience to ensure it's nothing short of extraordinary! We've listened closely to your feedback and made significant improvements geared towards transforming you into a ColdBox superhero. Learn What's New!

Maria Jose Herrera
Maria Jose Herrera
February 20, 2024
Ortus Redis Extension v3.3.0 Released!

Ortus Redis Extension v3.3.0 Released!

We are very excited to bring you another release for our Redis Lucee Extension. The most significant feature in this release is the addition of the `` and `redisLock{}` tag, which allows you perform a lock across all instances in a cluster.

Ortus Redis Extension v3.3.0 gives you greater control over concurrent modifications in a distributed environment, utilizing your distributed cache to prevent overlaps!

Jon Clausen
Jon Clausen
February 16, 2024
Introducing: 102 ColdBox HMVC Tips and Tricks

Introducing: 102 ColdBox HMVC Tips and Tricks

In this comprehensive guide, you'll discover a treasure trove of insights tailored to help you build sustainable ColdFusion applications using ColdBox HMVC. But that's not all – we've also included invaluable tips and tricks for companion libraries like CommandBox, WireBox, LogBox, CacheBox, and TestBox. Whether you are a beginner or a seasoned pro, you'll find something to elevate your skills and streamline your development process.

Maria Jose Herrera
Maria Jose Herrera
February 15, 2024